feat(sidebar): link Linear issues from Edit Worktree Details

The Issue field only accepted GitHub numbers, so a workspace tracking a
Linear issue had no way to say so from the dialog — the link could only be
set at creation time or through `orca worktree set --linear-issue`.

Replaces the field with one provider-aware row: a chip suffix inside the
input selects GitHub or Linear, and pasting a URL flips the chip to match.
A bare key never steers the provider — Linear and Jira issue keys are
byte-identical in shape, so shape alone cannot decide one.

One issue per workspace. A changed field displaces the other provider's
slot and the row names what Save is about to unlink. GitLab and Jira links
are left alone: the row cannot display them, and nothing else in the UI
could restore one it dropped.

- Folder workspaces read-only (their link is creation-time only)
- Remote runtimes assert the capability before writing or clearing, since
  `worktree.set` parses in strip mode and would silently drop the keys
- `updateWorktreeMeta` now reports failure so the dialog can stay open
  instead of closing over a save that refetch reverted
- Parses are length-bounded — `matchGitHubItemPath` strips trailing
  slashes with an unanchored regex that is quadratic on a large paste
This commit is contained in:
Jinjing
2026-08-03 18:34:25 -07:00
parent d7fe9d6bcc
commit babed44381
17 changed files with 1713 additions and 230 deletions
+14 -22
View File
@@ -1,17 +1,15 @@
import { parseLinearIssueInput } from '../../shared/linear-links'
import {
buildLinearIssueLinkUpdates,
LINEAR_ISSUE_LINK_CLEARED,
type LinearIssueLinkUpdates
} from '../../shared/linear-links'
import { RuntimeClientError } from '../runtime-client'
type LinearIssueLinkParams = {
linkedLinearIssue: string | null
linkedLinearIssueWorkspaceId: string | null
linkedLinearIssueOrganizationUrlKey: string | null
}
export function getOptionalLinearIssueLinkFlag(
flags: Map<string, string | boolean>,
name: string,
options: { allowNull?: boolean } = {}
): LinearIssueLinkParams | undefined {
): LinearIssueLinkUpdates | undefined {
const value = getPresentStringFlag(flags, name)
if (value === undefined) {
return undefined
@@ -24,28 +22,22 @@ export function getOptionalLinearIssueLinkFlag(
'Omit --linear-issue on create, or pass a Linear issue identifier or URL.'
)
}
return {
linkedLinearIssue: null,
linkedLinearIssueWorkspaceId: null,
linkedLinearIssueOrganizationUrlKey: null
}
return { ...LINEAR_ISSUE_LINK_CLEARED }
}
const parsed = parseLinearIssueInput(value)
if (!parsed) {
// Why: the shared builder treats empty input as "clear", which is right for a
// text field the user can blank but wrong for a flag — `--linear-issue " "`
// is a mistyped argument, not a request to unlink. Only the literal `null`
// clears, and that is handled above.
const updates = value.trim() === '' ? null : buildLinearIssueLinkUpdates(value)
if (!updates) {
throw new RuntimeClientError(
'invalid_argument',
'Pass a Linear issue identifier like STA-335, a Linear issue URL, or null to clear.'
)
}
return {
linkedLinearIssue: parsed.identifier,
// Why: changing a link must not keep a workspace id from a previous issue.
// The org key from URLs is enough for current-resolution to safely rehydrate it.
linkedLinearIssueWorkspaceId: null,
linkedLinearIssueOrganizationUrlKey: parsed.organizationUrlKey ?? null
}
return updates
}
function getPresentStringFlag(
+3
View File
@@ -1236,6 +1236,9 @@ describe('orca cli worktree awareness', () => {
linkedIssue: undefined,
linkedLinearIssue: 'STA-335',
linkedLinearIssueWorkspaceId: null,
// Why: a bare identifier carries no org, and the two scoping fields
// describe the issue being replaced. Inheriting a stale org key would
// pin the lookup to a workspace this issue does not live in.
linkedLinearIssueOrganizationUrlKey: null,
comment: undefined,
runHooks: false,
@@ -901,7 +901,11 @@ const WorktreeCard = React.memo(function WorktreeCard({
)
const handleRenameTitle = useCallback(
(displayName: string) => updateWorktreeMeta(worktree.id, { displayName }),
// Inline rename has no surface for the failure; the store already logs and
// refetches, which reverts the optimistic title in place.
async (displayName: string): Promise<void> => {
await updateWorktreeMeta(worktree.id, { displayName })
},
[updateWorktreeMeta, worktree.id]
)
@@ -0,0 +1,256 @@
import React, { useCallback, useId, useMemo } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { LinearIcon } from '@/components/icons/LinearIcon'
import { ChevronDown, ExternalLink, Github, LoaderCircle } from 'lucide-react'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import {
ISSUE_LINK_PROVIDERS,
isIssueLinkProvider,
type IssueLinkProvider
} from '../../../../shared/issue-link-input'
// Why: the value must clear the chip and the open-issue button that float over
// the input's right edge. Provider labels are localized, so the text share is
// measured in `ch` rather than baked into a Tailwind `pr-*` step that a longer
// translation would overlap.
const ADORNMENT_RESERVE_PX = 70
const MAX_RESERVE = '55%'
/** Exported for test: happy-dom drops `min()`, so this cannot be read back off a style. */
export function issueAdornmentReserve(providerLabel: string): string {
return `min(calc(${ADORNMENT_RESERVE_PX}px + ${providerLabel.length}ch), ${MAX_RESERVE})`
}
function providerLabel(provider: IssueLinkProvider): string {
return provider === 'linear'
? translate('auto.components.sidebar.WorktreeIssueLinkField.25852bfc59', 'Linear')
: translate('auto.components.sidebar.WorktreeIssueLinkField.5b440069e6', 'GitHub')
}
function ProviderIcon({
provider,
className
}: {
provider: IssueLinkProvider
className?: string
}): React.JSX.Element {
return provider === 'linear' ? (
<LinearIcon className={className} />
) : (
<Github className={className} />
)
}
export type WorktreeIssueLinkFieldProps = {
inputRef: React.RefObject<HTMLInputElement | null>
value: string
provider: IssueLinkProvider
/** Set when the current value cannot be saved, so the row can explain why. */
isInvalid: boolean
/** Names the links this save would drop; both slots can be filled at once. */
displacedLinkLabels: string[] | null
/** Folder workspaces have no persisted link slots — see the design doc. */
isReadOnly: boolean
canOpenIssue: boolean
openingIssue: boolean
/** The last open attempt resolved to nothing — see useWorktreeIssueLink. */
openIssueFailed: boolean
onValueChange: (value: string) => void
onProviderChange: (provider: IssueLinkProvider) => void
onOpenIssue: () => void
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void
}
export function WorktreeIssueLinkField(props: WorktreeIssueLinkFieldProps): React.JSX.Element {
const {
inputRef,
value,
provider,
isInvalid,
displacedLinkLabels,
isReadOnly,
canOpenIssue,
openingIssue,
openIssueFailed,
onValueChange,
onProviderChange,
onOpenIssue,
onKeyDown
} = props
// Why: the helper `<p>` is the only surface for the invalid and displacement
// states, so it has to be announced with the field rather than just seen.
const helperId = useId()
const inputId = useId()
const label = providerLabel(provider)
const openIssueLabel = translate(
'auto.components.sidebar.WorktreeIssueLinkField.161b2d053a',
'Open linked issue'
)
const handleProviderChange = useCallback(
(next: string) => {
if (isIssueLinkProvider(next)) {
onProviderChange(next)
}
},
[onProviderChange]
)
const helperText = useMemo(() => {
if (isReadOnly) {
return translate(
'auto.components.sidebar.WorktreeIssueLinkField.d4785f9954',
"Issue links are set when a folder workspace is created and can't be changed here yet."
)
}
if (isInvalid) {
return provider === 'linear'
? translate(
'auto.components.sidebar.WorktreeIssueLinkField.964d9bc00a',
'Not a Linear issue key or linear.app issue URL.'
)
: translate(
'auto.components.sidebar.WorktreeIssueLinkField.0a7a2c6efd',
'Not a GitHub issue number or issue URL.'
)
}
// Why: ranked above displacement because it answers the click the user just
// made, and it clears as soon as they edit the value that caused it.
if (openIssueFailed) {
return provider === 'linear'
? translate(
'auto.components.sidebar.WorktreeIssueLinkField.d8c8a30d1f',
"Couldn't open that issue. Check the identifier and your Linear connection."
)
: translate(
'auto.components.sidebar.WorktreeIssueLinkField.269198eeda',
"Couldn't open that issue. Check the number and your GitHub connection."
)
}
// Whole sentences per arity rather than a joined list: a translated " and "
// fragment would not survive languages that order or punctuate lists differently.
if (displacedLinkLabels && displacedLinkLabels.length > 1) {
return translate(
'auto.components.sidebar.WorktreeIssueLinkField.72486800ff',
'Saving unlinks {{first}} and {{second}} — a workspace tracks one issue.',
{ first: displacedLinkLabels[0], second: displacedLinkLabels[1] }
)
}
if (displacedLinkLabels?.length) {
return translate(
'auto.components.sidebar.WorktreeIssueLinkField.2c245ac134',
'Saving unlinks {{link}} — a workspace tracks one issue.',
{ link: displacedLinkLabels[0] }
)
}
return translate(
'auto.components.sidebar.WorktreeIssueLinkField.f047887705',
'Paste a GitHub or Linear URL, or enter a number. Leave blank to remove the link.'
)
}, [displacedLinkLabels, isInvalid, isReadOnly, openIssueFailed, provider])
return (
<div className="space-y-1">
<label htmlFor={inputId} className="text-[11px] font-medium text-muted-foreground">
{translate('auto.components.sidebar.WorktreeIssueLinkField.ad78f9bee2', 'Issue')}
</label>
<div className="relative">
<Input
ref={inputRef}
id={inputId}
aria-describedby={helperId}
value={value}
onChange={(e) => onValueChange(e.target.value)}
onKeyDown={onKeyDown}
disabled={isReadOnly}
aria-invalid={isInvalid || undefined}
placeholder={translate(
'auto.components.sidebar.WorktreeIssueLinkField.662ae142f8',
'Issue #, or a GitHub or Linear URL'
)}
className="h-8 text-xs"
style={{ paddingRight: issueAdornmentReserve(label) }}
/>
<div className="absolute right-1 top-1 flex items-center gap-0.5">
{/* Why: `modal` would stack a second scroll-lock layer over the
Dialog's, whose nested teardown strands pointer-events on body. */}
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="xs"
disabled={isReadOnly}
aria-label={translate(
'auto.components.sidebar.WorktreeIssueLinkField.929c98d05a',
'Issue provider'
)}
className="h-6 px-1 text-[10px] font-medium text-muted-foreground"
>
<ProviderIcon provider={provider} className="size-3" />
{label}
<ChevronDown className="size-2.5 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-32">
<DropdownMenuRadioGroup value={provider} onValueChange={handleProviderChange}>
{ISSUE_LINK_PROVIDERS.map((item) => (
<DropdownMenuRadioItem key={item} value={item} className="text-xs">
<ProviderIcon provider={item} className="size-3" />
{providerLabel(item)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={openIssueLabel}
disabled={!canOpenIssue || openingIssue}
onClick={onOpenIssue}
className="text-muted-foreground"
>
{openingIssue ? (
<LoaderCircle className="size-3 animate-spin" />
) : (
<ExternalLink className="size-3" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{openIssueLabel}
</TooltipContent>
</Tooltip>
</div>
</div>
{/* Why: two lines are reserved up front. The dialog is centered by
transform, so a wrap-count change would shift the field being typed in. */}
<p
id={helperId}
className={cn(
'min-h-[28px] text-[10px] leading-[14px]',
(openIssueFailed || displacedLinkLabels?.length) && !isInvalid && !isReadOnly
? 'text-amber-600 dark:text-amber-400'
: 'text-muted-foreground'
)}
>
{helperText}
</p>
</div>
)
}
@@ -0,0 +1,341 @@
// @vitest-environment happy-dom
import { act, type ReactNode } from 'react'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import type { FolderWorkspace, Repo, Worktree, WorktreeMeta } from '../../../../shared/types'
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
// Why: Radix tooltips need a provider the dialog does not own, and the menu's
// portal needs real layout. Stand-ins keep these tests on provider selection.
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children?: ReactNode }) => <>{children}</>,
TooltipContent: () => null,
TooltipTrigger: ({ children }: { children?: ReactNode }) => <>{children}</>
}))
vi.mock('@/components/ui/dropdown-menu', async () => {
const React = await import('react')
const SelectContext = React.createContext<(value: string) => void>(() => {})
const Passthrough = ({ children }: { children?: ReactNode }) => <>{children}</>
return {
DropdownMenu: Passthrough,
DropdownMenuTrigger: Passthrough,
DropdownMenuContent: Passthrough,
DropdownMenuRadioGroup: ({
value,
onValueChange,
children
}: {
value: string
onValueChange: (value: string) => void
children?: ReactNode
}) => (
<SelectContext.Provider value={onValueChange}>
<div data-selected={value}>{children}</div>
</SelectContext.Provider>
),
DropdownMenuRadioItem: ({ value, children }: { value: string; children?: ReactNode }) => {
const onSelect = React.useContext(SelectContext)
return (
<button type="button" role="menuitemradio" onClick={() => onSelect(value)}>
{children}
</button>
)
}
}
})
import WorktreeMetaDialog from './WorktreeMetaDialog'
const REPO_ID = 'repo-1'
const WORKTREE_ID = 'repo-1::/repo/worktrees/feature'
const initialState = useAppStore.getInitialState()
const updateWorktreeMeta =
vi.fn<
(
id: string,
updates: Partial<WorktreeMeta>
) => Promise<{ ok: true } | { ok: false; error: string }>
>()
function makeRepo(): Repo {
return { id: REPO_ID, path: '/repo', displayName: 'orca', badgeColor: '#999999', addedAt: 1 }
}
function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
return {
id: WORKTREE_ID,
repoId: REPO_ID,
path: '/repo/worktrees/feature',
displayName: 'Feature work',
branch: 'feature',
head: 'abc123',
isBare: false,
isMainWorktree: false,
comment: 'existing note',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 1,
...overrides
}
}
function makeFolderWorkspace(overrides: Partial<FolderWorkspace> = {}): FolderWorkspace {
return {
id: 'fw-1',
projectGroupId: 'pg-1',
name: 'Docs folder',
folderPath: '/repo/docs',
linkedTask: {
provider: 'linear',
type: 'issue',
number: 901,
title: 'Fix auth',
url: 'https://linear.app/acme/issue/STA-901',
linearIdentifier: 'STA-901'
},
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 1,
createdAt: 1,
updatedAt: 1,
...overrides
}
}
function openDialog(
options: {
worktree?: Partial<Worktree>
worktreeId?: string
folderWorkspace?: Partial<FolderWorkspace>
} = {}
): void {
const worktree = makeWorktree(options.worktree)
useAppStore.setState({
repos: [makeRepo()],
worktreesByRepo: { [REPO_ID]: [worktree] },
...(options.folderWorkspace
? { folderWorkspaces: [makeFolderWorkspace(options.folderWorkspace)] }
: {}),
activeModal: 'edit-meta',
modalData: {
worktreeId: options.worktreeId ?? worktree.id,
currentDisplayName: worktree.displayName,
currentComment: worktree.comment,
focus: 'comment'
},
updateWorktreeMeta
})
render(<WorktreeMetaDialog />)
}
function issueInput(): HTMLInputElement {
return screen.getByPlaceholderText('Issue #, or a GitHub or Linear URL')
}
function providerChip(): HTMLButtonElement {
return screen.getByRole('button', { name: 'Issue provider' })
}
function saveButton(): HTMLButtonElement {
return screen.getByRole('button', { name: 'Save' })
}
describe('WorktreeMetaDialog issue link row', () => {
beforeEach(() => {
useAppStore.setState(initialState, true)
updateWorktreeMeta.mockReset()
updateWorktreeMeta.mockResolvedValue({ ok: true })
Object.defineProperty(window, 'api', {
configurable: true,
value: { shell: { openUrl: vi.fn() } }
})
})
afterEach(() => {
cleanup()
useAppStore.setState(initialState, true)
})
it('seeds the chip and value from a GitHub link', () => {
openDialog({ worktree: { linkedIssue: 42 } })
expect(providerChip().textContent).toContain('GitHub')
expect(issueInput().value).toBe('42')
})
it('seeds the chip and value from a Linear link', () => {
openDialog({ worktree: { linkedLinearIssue: 'STA-335' } })
expect(providerChip().textContent).toContain('Linear')
expect(issueInput().value).toBe('STA-335')
})
it('flips to Linear when a linear.app issue URL is pasted', () => {
openDialog({ worktree: { linkedIssue: 42 } })
fireEvent.change(issueInput(), {
target: { value: 'https://linear.app/acme/issue/STA-335/fix-the-thing' }
})
expect(providerChip().textContent).toContain('Linear')
})
// Why: Linear and Jira issue keys are the same shape, so only a URL may steer
// the provider — a bare key must never override the user's explicit choice.
it('keeps the chip on GitHub when a bare issue key is typed', () => {
openDialog({ worktree: { linkedIssue: 42 } })
fireEvent.change(issueInput(), { target: { value: 'GH-1234' } })
expect(providerChip().textContent).toContain('GitHub')
expect(providerChip().textContent).not.toContain('Linear')
})
it('flips to GitHub when a GitHub issue URL is pasted over a Linear link', () => {
openDialog({ worktree: { linkedLinearIssue: 'STA-335' } })
fireEvent.change(issueInput(), {
target: { value: 'https://github.com/acme/orca/issues/77' }
})
expect(providerChip().textContent).toContain('GitHub')
})
it('names the Linear issue that switching to GitHub would unlink', () => {
openDialog({ worktree: { linkedLinearIssue: 'STA-335' } })
fireEvent.click(screen.getByRole('menuitemradio', { name: 'GitHub' }))
fireEvent.change(issueInput(), { target: { value: '99' } })
expect(
screen.getByText('Saving unlinks Linear STA-335 — a workspace tracks one issue.')
).toBeTruthy()
})
// Both slots can hold a link at once — naming only one understates the save.
it('names both links when clearing the field would drop both', () => {
openDialog({ worktree: { linkedIssue: 42, linkedLinearIssue: 'STA-335' } })
fireEvent.change(issueInput(), { target: { value: '' } })
expect(
screen.getByText(
'Saving unlinks Linear STA-335 and GitHub #42 — a workspace tracks one issue.'
)
).toBeTruthy()
})
// A failed save refetches and reverts the optimistic write, so closing here
// would report success for an edit that silently undid itself.
it('keeps the dialog open and reports why when the save fails', async () => {
openDialog({ worktree: { linkedLinearIssue: 'STA-335' } })
updateWorktreeMeta.mockResolvedValue({ ok: false, error: 'Runtime is offline' })
fireEvent.change(issueInput(), { target: { value: 'STA-999' } })
await act(async () => {
fireEvent.click(saveButton())
})
expect(screen.getByRole('alert').textContent).toBe('Runtime is offline')
expect(useAppStore.getState().activeModal).toBe('edit-meta')
})
it('leaves the Linear link alone when only the comment is edited', async () => {
openDialog({ worktree: { linkedLinearIssue: 'STA-335' } })
fireEvent.change(screen.getByPlaceholderText('Notes about this worktree...'), {
target: { value: 'updated note' }
})
expect(screen.queryByText(/Saving unlinks/)).toBeNull()
await act(async () => {
fireEvent.click(saveButton())
})
await waitFor(() => expect(updateWorktreeMeta).toHaveBeenCalledTimes(1))
const updates = updateWorktreeMeta.mock.calls[0]?.[1] ?? {}
expect(Object.keys(updates)).not.toContain('linkedLinearIssue')
expect(Object.keys(updates)).not.toContain('linkedIssue')
expect(updates.comment).toBe('updated note')
})
it('blocks saving an unparseable Linear value', () => {
openDialog({ worktree: { linkedLinearIssue: 'STA-335' } })
fireEvent.change(issueInput(), { target: { value: 'not an issue' } })
expect(screen.getByText('Not a Linear issue key or linear.app issue URL.')).toBeTruthy()
expect(saveButton().disabled).toBe(true)
})
it('is read-only for a folder workspace', () => {
openDialog({ worktreeId: folderWorkspaceKey('fw-1') })
expect(issueInput().disabled).toBe(true)
expect(providerChip().disabled).toBe(true)
expect(
screen.getByText(
"Issue links are set when a folder workspace is created and can't be changed here yet."
)
).toBeTruthy()
})
// Folder workspaces live outside worktreesByRepo, so the indexed lookup alone
// leaves the row blank and the link it does hold looks lost.
it('shows a folder workspace its own linked issue', () => {
openDialog({ worktreeId: folderWorkspaceKey('fw-1'), folderWorkspace: {} })
expect(issueInput().value).toBe('STA-901')
expect(providerChip().textContent).toContain('Linear')
})
// A background `orca worktree set` must not move the baseline mid-edit: the
// field would read as dirty and a comment-only save would write the stale seed.
it('keeps the baseline frozen when the store changes while open', async () => {
openDialog({ worktree: { linkedLinearIssue: 'STA-335' } })
act(() => {
useAppStore.setState({
worktreesByRepo: {
[REPO_ID]: [makeWorktree({ linkedLinearIssue: 'STA-999' })]
}
})
})
fireEvent.change(screen.getByPlaceholderText('Notes about this worktree...'), {
target: { value: 'still working' }
})
await act(async () => {
fireEvent.click(saveButton())
})
const updates = updateWorktreeMeta.mock.calls[0]?.[1] ?? {}
expect(updates.comment).toBe('still working')
expect(updates).not.toHaveProperty('linkedLinearIssue')
expect(updates).not.toHaveProperty('linkedIssue')
})
it('dispatches nothing when the dialog is cancelled', async () => {
openDialog({ worktree: { linkedLinearIssue: 'STA-335' } })
fireEvent.change(issueInput(), { target: { value: '99' } })
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
})
expect(updateWorktreeMeta).not.toHaveBeenCalled()
expect(useAppStore.getState().activeModal).toBe('none')
})
})
@@ -10,23 +10,41 @@ import {
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { getDisplacedLinkLabels } from './worktree-issue-displacement'
import {
buildWorktreeMetaUpdates,
parseGitHubWorkItemNumberForMetaField,
type WorktreeMetaSavedPayload
type WorktreeMetaDraft,
type WorktreeMetaSavedPayload,
type WorktreeMetaSnapshot
} from './worktree-meta-updates'
import { useWorktreeIssueLink } from './use-worktree-issue-link'
import { WorktreeIssueLinkField } from './WorktreeIssueLinkField'
import { getScreenSubmitShortcutLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
import { ExternalLink, LoaderCircle } from 'lucide-react'
import { useMountedRef } from '@/hooks/useMountedRef'
import { translate } from '@/i18n/i18n'
import { findIndexedWorktreeOwner } from '@/lib/worktree-runtime-owner-index'
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree'
import { isWorkItemLinkQueryTooLarge } from '../../../../shared/new-workspace/work-item-link-query-bounds'
import {
getIssueLinkProviderFromUrl,
parseIssueLinkInput,
type IssueLinkProvider
} from '../../../../shared/issue-link-input'
function resizeCommentTextarea(textarea: HTMLTextAreaElement): void {
textarea.style.height = 'auto'
textarea.style.height = `${textarea.scrollHeight}px`
}
/** Only read before the first open, when nothing can be saved yet. */
const EMPTY_SNAPSHOT: WorktreeMetaSnapshot = {
displayName: '',
issueInput: '',
issueProvider: 'github'
}
const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
const activeModal = useAppStore((s) => s.activeModal)
const modalData = useAppStore((s) => s.modalData)
@@ -40,9 +58,6 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
const worktreeId = typeof modalData.worktreeId === 'string' ? modalData.worktreeId : ''
const currentDisplayName =
typeof modalData.currentDisplayName === 'string' ? modalData.currentDisplayName : ''
const currentIssue =
typeof modalData.currentIssue === 'number' ? String(modalData.currentIssue) : ''
const currentPR = typeof modalData.currentPR === 'number' ? String(modalData.currentPR) : ''
const currentComment =
typeof modalData.currentComment === 'string' ? modalData.currentComment : ''
const focusField = typeof modalData.focus === 'string' ? modalData.focus : 'comment'
@@ -51,15 +66,73 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
? (modalData.afterSave as (payload: WorktreeMetaSavedPayload) => void | Promise<void>)
: null
const workspaceScope = useMemo(() => parseWorkspaceKey(worktreeId), [worktreeId])
// Why: link state is read from the store rather than threaded through the
// untyped modalData bag — a call site that forgot a key would otherwise seed
// an empty field and silently destroy the link on save.
const indexedWorktree = useAppStore((s) => {
const owner = findIndexedWorktreeOwner(s.worktreesByRepo, worktreeId)
return owner
? s.worktreesByRepo[owner.repoId]?.find((item) => item.id === worktreeId)
: undefined
})
// Why: folder workspaces are absent from worktreesByRepo, so the lookup above
// returns undefined and the row renders blank for them. The selector returns
// the stored record — a stable reference — and the projection happens outside
// it, because a selector that built the object would return a fresh identity
// on every store write and re-render the dialog continuously.
const folderWorkspace = useAppStore((s) =>
workspaceScope?.type === 'folder'
? (s.folderWorkspaces.find((item) => item.id === workspaceScope.folderWorkspaceId) ?? null)
: null
)
const worktree = useMemo(
() => (folderWorkspace ? folderWorkspaceToWorktree(folderWorkspace) : indexedWorktree),
[folderWorkspace, indexedWorktree]
)
const linkedIssue = worktree?.linkedIssue ?? null
const linkedLinearIssue = worktree?.linkedLinearIssue ?? null
// Why: ChecksPanel seeds the PR it is looking at, which may not be linked yet.
const currentPR =
typeof modalData.currentPR === 'number'
? String(modalData.currentPR)
: worktree?.linkedPR != null
? String(worktree.linkedPR)
: ''
// Why: `typeof` rather than a null check — an unhydrated projection can leave
// linkedIssue undefined, which `!== null` would read as a GitHub link.
const currentProvider: IssueLinkProvider =
typeof linkedIssue === 'number' ? 'github' : linkedLinearIssue ? 'linear' : 'github'
// Why: the value and its provider must come from one source. Every call site
// passed `currentIssue: worktree.linkedIssue`, so the modalData seed was pure
// duplication that could only ever drift from the chip.
const currentIssue =
currentProvider === 'linear'
? (linkedLinearIssue ?? '')
: typeof linkedIssue === 'number'
? String(linkedIssue)
: ''
// Why: folder workspaces persist links only through their creation-time
// linkedTask; updateWorktreeMeta drops link keys for them entirely.
const isFolderWorkspace = workspaceScope?.type === 'folder'
const [displayNameInput, setDisplayNameInput] = useState('')
const [issueInput, setIssueInput] = useState('')
const [issueProvider, setIssueProvider] = useState<IssueLinkProvider>('github')
const [prInput, setPrInput] = useState('')
const [commentInput, setCommentInput] = useState('')
const [saving, setSaving] = useState(false)
const { canOpenIssue, openingIssue, handleOpenIssue, resetOpeningIssue } = useWorktreeIssueLink({
worktreeId,
issueInput
})
const [saveError, setSaveError] = useState<string | null>(null)
const [snapshot, setSnapshot] = useState<WorktreeMetaSnapshot>(EMPTY_SNAPSHOT)
const { canOpenIssue, openingIssue, openIssueFailed, handleOpenIssue, resetOpeningIssue } =
useWorktreeIssueLink({
worktreeId,
issueInput,
issueProvider,
linearOrganizationUrlKey: worktree?.linkedLinearIssueOrganizationUrlKey ?? null,
linkedLinearIssue: worktree?.linkedLinearIssue ?? null,
linearSourceContext: worktree?.linkedTaskSourceContext ?? null
})
const issueInputRef = useRef<HTMLInputElement>(null)
const prInputRef = useRef<HTMLInputElement>(null)
@@ -70,12 +143,42 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
if (isOpen && !prevIsOpenRef.current) {
setDisplayNameInput(currentDisplayName)
setIssueInput(currentIssue)
setIssueProvider(currentProvider)
setPrInput(currentPR)
setCommentInput(currentComment)
// Why: the baseline is frozen with the seed instead of tracking the store.
// A background `orca worktree set --linear-issue` while the dialog is open
// would otherwise move it, making the untouched field read as dirty — and
// the next comment-only save would write the stale seed back over the new link.
setSnapshot({
displayName: currentDisplayName,
issueInput: currentIssue,
issueProvider: currentProvider,
linkedWorkItemProvider: worktree?.linkedWorkItem?.provider ?? null,
linkedWorkItemType: worktree?.linkedWorkItem?.type ?? null
})
setSaveError(null)
resetOpeningIssue()
}
prevIsOpenRef.current = isOpen
const draft = useMemo<WorktreeMetaDraft>(
() => ({ displayNameInput, issueInput, issueProvider, prInput, commentInput }),
[displayNameInput, issueInput, issueProvider, prInput, commentInput]
)
// Why: a URL names its provider unambiguously. A bare key does not — Linear
// and Jira identifiers are the same shape — so typing never steers the chip.
const handleIssueInputChange = useCallback((next: string) => {
setIssueInput(next)
// Why: the same bound the save gate applies. Detection parses on every
// keystroke, and the field accepts pasted URLs of any length.
const detected = isWorkItemLinkQueryTooLarge(next) ? null : getIssueLinkProviderFromUrl(next)
if (detected) {
setIssueProvider(detected)
}
}, [])
const setCommentTextareaRef = useCallback(
(textarea: HTMLTextAreaElement | null) => {
textareaRef.current = textarea
@@ -92,18 +195,42 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
resizeCommentTextarea(event.currentTarget)
}, [])
// Why: bound the parse before it runs on every keystroke — the field accepts
// pasted URLs and has no length cap of its own.
const issueInvalid = useMemo(() => {
const trimmed = issueInput.trim()
if (trimmed === '' || isFolderWorkspace) {
return false
}
return (
isWorkItemLinkQueryTooLarge(trimmed) || parseIssueLinkInput(trimmed, issueProvider) === null
)
}, [isFolderWorkspace, issueInput, issueProvider])
const canSave = useMemo(() => {
if (!worktreeId) {
return false
}
const trimmedIssue = issueInput.trim()
const trimmedPR = prInput.trim()
const issueValid =
trimmedIssue === '' || parseGitHubWorkItemNumberForMetaField(trimmedIssue, 'issue') !== null
// Same quadratic-parse bound as the issue field — this runs on every keystroke.
const prValid =
trimmedPR === '' || parseGitHubWorkItemNumberForMetaField(trimmedPR, 'pr') !== null
return issueValid && prValid
}, [worktreeId, issueInput, prInput])
trimmedPR === '' ||
(!isWorkItemLinkQueryTooLarge(trimmedPR) &&
parseGitHubWorkItemNumberForMetaField(trimmedPR, 'pr') !== null)
return !issueInvalid && prValid
}, [worktreeId, issueInvalid, prInput])
const displacedLinkLabels = useMemo(
() =>
getDisplacedLinkLabels({
draft,
snapshot,
isFolderWorkspace,
linkedIssue,
linkedLinearIssue
}),
[draft, snapshot, isFolderWorkspace, linkedIssue, linkedLinearIssue]
)
const handleOpenChange = useCallback(
(open: boolean) => {
@@ -120,15 +247,18 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
}
setSaving(true)
try {
const updates = buildWorktreeMetaUpdates({
displayNameInput,
currentDisplayName,
issueInput,
prInput,
commentInput
})
const updates = buildWorktreeMetaUpdates(draft, snapshot)
await updateWorktreeMeta(worktreeId, updates)
const result = await updateWorktreeMeta(worktreeId, updates)
// Why: a failed save refetches and reverts the optimistic write. Closing
// here would report success for an edit that silently undid itself, and
// would discard the name, comment and PR changes in the same payload.
if (!result.ok) {
if (mountedRef.current) {
setSaveError(result.error)
}
return
}
closeModal()
// Why: follow-up refreshes should not turn a successful metadata save
// into a failed dialog.
@@ -142,19 +272,7 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
setSaving(false)
}
}
}, [
worktreeId,
canSave,
displayNameInput,
currentDisplayName,
issueInput,
prInput,
commentInput,
updateWorktreeMeta,
closeModal,
afterSave,
mountedRef
])
}, [worktreeId, canSave, draft, snapshot, updateWorktreeMeta, closeModal, afterSave, mountedRef])
const handleCommentKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
@@ -204,8 +322,8 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
</DialogTitle>
<DialogDescription className="text-xs">
{translate(
'auto.components.sidebar.WorktreeMetaDialog.65770ad0f0',
'Edit GitHub links and notes for this workspace.'
'auto.components.sidebar.WorktreeMetaDialog.a0d191b7a7',
'Edit issue links, pull request links, and notes for this workspace.'
)}
</DialogDescription>
</DialogHeader>
@@ -234,58 +352,21 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
</p>
</div>
<div className="space-y-1">
<label className="text-[11px] font-medium text-muted-foreground">
{translate('auto.components.sidebar.WorktreeMetaDialog.645fa4a0fd', 'GH Issue')}
</label>
<div className="relative">
<Input
ref={issueInputRef}
value={issueInput}
onChange={(e) => setIssueInput(e.target.value)}
onKeyDown={handleIssueKeyDown}
placeholder={translate(
'auto.components.sidebar.WorktreeMetaDialog.741279e7b7',
'Issue # or GitHub URL'
)}
className="h-8 pr-9 text-xs"
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={translate(
'auto.components.sidebar.WorktreeMetaDialog.029ea5ec57',
'Open GitHub issue'
)}
disabled={!canOpenIssue || openingIssue}
onClick={handleOpenIssue}
className="absolute right-1 top-1 text-muted-foreground"
>
{openingIssue ? (
<LoaderCircle className="size-3 animate-spin" />
) : (
<ExternalLink className="size-3" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{translate(
'auto.components.sidebar.WorktreeMetaDialog.029ea5ec57',
'Open GitHub issue'
)}
</TooltipContent>
</Tooltip>
</div>
<p className="text-[10px] text-muted-foreground">
{translate(
'auto.components.sidebar.WorktreeMetaDialog.7c454be4c5',
'Paste an issue URL, or enter a number. Leave blank to remove the link.'
)}
</p>
</div>
<WorktreeIssueLinkField
inputRef={issueInputRef}
value={issueInput}
provider={issueProvider}
isInvalid={issueInvalid}
displacedLinkLabels={displacedLinkLabels}
isReadOnly={isFolderWorkspace}
canOpenIssue={canOpenIssue}
openingIssue={openingIssue}
openIssueFailed={openIssueFailed}
onValueChange={handleIssueInputChange}
onProviderChange={setIssueProvider}
onOpenIssue={handleOpenIssue}
onKeyDown={handleIssueKeyDown}
/>
<div className="space-y-1">
<label className="text-[11px] font-medium text-muted-foreground">
@@ -340,6 +421,12 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {
</div>
</div>
{saveError ? (
<p role="alert" className="text-[11px] leading-[15px] text-destructive">
{saveError}
</p>
) : null}
<DialogFooter>
<Button
variant="outline"
@@ -3,36 +3,127 @@ import { useAppStore } from '@/store'
import { parseGitHubIssueOrPRNumber } from '@/lib/github-links'
import { issueCacheKey as getIssueCacheKey } from '@/store/slices/github'
import { useMountedRef } from '@/hooks/useMountedRef'
import { findIndexedWorktreeOwner } from '@/lib/worktree-runtime-owner-index'
import { buildLinearIssueUrl, parseLinearIssueInput } from '../../../../shared/linear-links'
import type { IssueLinkProvider } from '../../../../shared/issue-link-input'
import type { TaskSourceContext } from '../../../../shared/task-source-context'
import { parseExplicitGitHubIssueUrl } from './worktree-meta-updates'
import { isWorkItemLinkQueryTooLarge } from '../../../../shared/new-workspace/work-item-link-query-bounds'
// Why: both lookups cross an IPC or runtime-RPC boundary that can stop
// answering — a hung SSH runtime leaves the await pending, and the button spins
// for the life of the dialog. Set above the 30s Linear/GitHub client bounds, so a
// slow-but-live transport reports its own failure rather than being called a bad
// identifier here.
const OPEN_ISSUE_TIMEOUT_MS = 35_000
async function resolveIssueUrlWithinTimeout(
lookup: Promise<{ url?: string } | null>
): Promise<string | null> {
let timer: ReturnType<typeof setTimeout> | undefined
try {
const issue = await Promise.race([
lookup,
new Promise<null>((resolve) => {
timer = setTimeout(() => resolve(null), OPEN_ISSUE_TIMEOUT_MS)
})
])
return issue?.url ?? null
} catch {
// Why: the store's fetchers already log and normalize failures to null; a
// rejection here is a transport fault and reads the same to the user.
return null
} finally {
clearTimeout(timer)
}
}
/** Resolves the "open linked issue" affordance for the worktree meta dialog:
* explicit URLs open directly, numbers resolve via the issue cache or an
* owner-routed fetch. */
export function useWorktreeIssueLink(args: { worktreeId: string; issueInput: string }): {
* explicit URLs open directly, GitHub numbers resolve via the issue cache or an
* owner-routed fetch, Linear identifiers via the stored org key or a lookup. */
export function useWorktreeIssueLink(args: {
worktreeId: string
issueInput: string
issueProvider: IssueLinkProvider
linearOrganizationUrlKey?: string | null
/** The persisted identifier the stored org key belongs to. */
linkedLinearIssue?: string | null
linearSourceContext?: TaskSourceContext | null
}): {
canOpenIssue: boolean
openingIssue: boolean
/** A lookup came back empty: wrong identifier, no access, or provider offline. */
openIssueFailed: boolean
handleOpenIssue: () => Promise<void>
resetOpeningIssue: () => void
} {
const { worktreeId, issueInput } = args
const {
worktreeId,
issueInput,
issueProvider,
linearOrganizationUrlKey,
linkedLinearIssue,
linearSourceContext
} = args
const isLinear = issueProvider === 'linear'
const fetchIssue = useAppStore((s) => s.fetchIssue)
const fetchLinearIssue = useAppStore((s) => s.fetchLinearIssue)
const viewerOrganizationUrlKey = useAppStore(
(s) => s.linearStatus?.viewer?.organizationUrlKey ?? null
)
const [openingIssue, setOpeningIssue] = useState(false)
const [failedIssueInput, setFailedIssueInput] = useState<string | null>(null)
const mountedRef = useMountedRef()
const issueNumber = useMemo(() => parseGitHubIssueOrPRNumber(issueInput), [issueInput])
const issueUrlFromInput = useMemo(() => parseExplicitGitHubIssueUrl(issueInput), [issueInput])
const issueInputLooksLikeUrl = useMemo(
() => /^https?:\/\//i.test(issueInput.trim()),
// Why: `matchGitHubItemPath` strips trailing slashes with an unanchored `/\/+$/`,
// which is quadratic — a 160 KB paste of slashes freezes the renderer for ~19s.
// These parses run on every keystroke, so they get the same bound the save gate
// applies. The check short-circuits on length, so it stays O(1) on a huge paste.
const boundedInput = useMemo(
() => (isWorkItemLinkQueryTooLarge(issueInput) ? '' : issueInput),
[issueInput]
)
const issueRepo = useAppStore((s) => {
const worktree = Object.values(s.worktreesByRepo)
.flat()
.find((item) => item.id === worktreeId)
if (!worktree) {
return undefined
const issueNumber = useMemo(
() => (isLinear ? null : parseGitHubIssueOrPRNumber(boundedInput)),
[isLinear, boundedInput]
)
const issueUrlFromInput = useMemo(
() => (isLinear ? null : parseExplicitGitHubIssueUrl(boundedInput)),
[isLinear, boundedInput]
)
const issueInputLooksLikeUrl = useMemo(
() => /^https?:\/\//i.test(boundedInput.trim()),
[boundedInput]
)
const parsedLinearIssue = useMemo(
() => (isLinear ? parseLinearIssueInput(boundedInput) : null),
[isLinear, boundedInput]
)
// Why: the input's own org key wins, then the workspace's stored one, then the
// connected viewer's — a bare identifier is only resolvable through the latter two.
// The stored key describes the persisted issue, so it only applies while the typed
// identifier still *is* that issue; reusing it for a newly typed one builds a URL
// in the wrong org and short-circuits the lookup that would have reported the miss.
const linearIssueUrl = useMemo(() => {
if (!parsedLinearIssue) {
return null
}
return s.repos.find((repo) => repo.id === worktree.repoId)
const storedKeyApplies =
typeof linkedLinearIssue === 'string' &&
linkedLinearIssue.toUpperCase() === parsedLinearIssue.identifier.toUpperCase()
return buildLinearIssueUrl({
identifier: parsedLinearIssue.identifier,
organizationUrlKey:
parsedLinearIssue.organizationUrlKey ??
(storedKeyApplies ? linearOrganizationUrlKey : null) ??
viewerOrganizationUrlKey
})
}, [parsedLinearIssue, linkedLinearIssue, linearOrganizationUrlKey, viewerOrganizationUrlKey])
const issueRepo = useAppStore((s) => {
const owner = findIndexedWorktreeOwner(s.worktreesByRepo, worktreeId)
return owner ? s.repos.find((repo) => repo.id === owner.repoId) : undefined
})
const cachedIssueUrl = useAppStore((s) => {
if (!issueRepo || issueNumber === null) {
@@ -52,14 +143,51 @@ export function useWorktreeIssueLink(args: { worktreeId: string; issueInput: str
]?.data?.url ?? null
)
})
const canOpenIssue = issueInputLooksLikeUrl
? Boolean(issueUrlFromInput)
: Boolean(cachedIssueUrl || (issueRepo && issueNumber))
const canOpenIssue = isLinear
? Boolean(parsedLinearIssue)
: issueInputLooksLikeUrl
? Boolean(issueUrlFromInput)
: Boolean(cachedIssueUrl || (issueRepo && issueNumber))
const handleOpenIssue = useCallback(async () => {
if (openingIssue) {
return
}
setFailedIssueInput(null)
if (isLinear) {
if (!parsedLinearIssue) {
return
}
if (linearIssueUrl) {
void window.api.shell.openUrl(linearIssueUrl)
return
}
setOpeningIssue(true)
try {
// Why: 'all' — the issue may belong to a different Linear workspace than
// the selected one, which is the usual case for a pasted identifier.
const url = await resolveIssueUrlWithinTimeout(
fetchLinearIssue(parsedLinearIssue.identifier, 'all', {
sourceContext: linearSourceContext ?? null
})
)
if (url) {
void window.api.shell.openUrl(url)
} else if (mountedRef.current) {
// Why: the fetchers normalize every failure — no Linear connection,
// unknown identifier, dead runtime — to null, so without this the
// button just stops spinning and the click looks ignored.
setFailedIssueInput(issueInput)
}
} finally {
if (mountedRef.current) {
setOpeningIssue(false)
}
}
return
}
if (issueUrlFromInput) {
void window.api.shell.openUrl(issueUrlFromInput)
@@ -81,9 +209,13 @@ export function useWorktreeIssueLink(args: { worktreeId: string; issueInput: str
setOpeningIssue(true)
try {
const issue = await fetchIssue(issueRepo.path, issueNumber, { repoId: issueRepo.id })
if (issue?.url) {
void window.api.shell.openUrl(issue.url)
const url = await resolveIssueUrlWithinTimeout(
fetchIssue(issueRepo.path, issueNumber, { repoId: issueRepo.id })
)
if (url) {
void window.api.shell.openUrl(url)
} else if (mountedRef.current) {
setFailedIssueInput(issueInput)
}
} finally {
if (mountedRef.current) {
@@ -93,15 +225,33 @@ export function useWorktreeIssueLink(args: { worktreeId: string; issueInput: str
}, [
cachedIssueUrl,
fetchIssue,
fetchLinearIssue,
isLinear,
issueInput,
issueInputLooksLikeUrl,
issueNumber,
issueRepo,
issueUrlFromInput,
linearIssueUrl,
linearSourceContext,
mountedRef,
openingIssue
openingIssue,
parsedLinearIssue
])
const resetOpeningIssue = useCallback(() => setOpeningIssue(false), [])
const resetOpeningIssue = useCallback(() => {
setOpeningIssue(false)
setFailedIssueInput(null)
}, [])
return { canOpenIssue, openingIssue, handleOpenIssue, resetOpeningIssue }
return {
canOpenIssue,
openingIssue,
// Why: the failure belongs to the value that produced it. Editing the field
// is the user's answer to it, so comparing rather than clearing on change
// retires the notice without an Effect that would lag a keystroke behind.
openIssueFailed: failedIssueInput !== null && failedIssueInput === issueInput,
handleOpenIssue,
resetOpeningIssue
}
}
@@ -0,0 +1,37 @@
import type { IssueLinkProvider } from '../../../../shared/issue-link-input'
import {
isIssueFieldDirty,
type WorktreeMetaDraft,
type WorktreeMetaSnapshot
} from './worktree-meta-updates'
function formatLinkLabel(provider: IssueLinkProvider, value: string): string {
return provider === 'linear' ? `Linear ${value}` : `GitHub #${value}`
}
/** Names the persisted links a save would drop. A workspace tracks one issue, so
* a changed field displaces the other provider's slot and clearing drops both.
* Only a dirty field displaces anything — the dialog opens focused on Comment,
* and an untouched row must leave every link alone. */
export function getDisplacedLinkLabels(args: {
draft: WorktreeMetaDraft
snapshot: WorktreeMetaSnapshot
isFolderWorkspace: boolean
linkedIssue: number | null
linkedLinearIssue: string | null
}): string[] | null {
const { draft, snapshot, isFolderWorkspace, linkedIssue, linkedLinearIssue } = args
if (isFolderWorkspace || !isIssueFieldDirty(draft, snapshot)) {
return null
}
const keeping = draft.issueInput.trim() === '' ? null : draft.issueProvider
const displaced: string[] = []
if (keeping !== 'linear' && linkedLinearIssue) {
displaced.push(formatLinkLabel('linear', linkedLinearIssue))
}
if (keeping !== 'github' && typeof linkedIssue === 'number') {
displaced.push(formatLinkLabel('github', String(linkedIssue)))
}
return displaced.length > 0 ? displaced : null
}
@@ -1,77 +1,234 @@
import { describe, expect, it } from 'vitest'
import { buildWorktreeMetaUpdates } from './worktree-meta-updates'
import type { WorktreeMeta } from '../../../../shared/types'
import {
buildWorktreeMetaUpdates,
type WorktreeMetaDraft,
type WorktreeMetaSnapshot
} from './worktree-meta-updates'
function makeDraft(overrides: Partial<WorktreeMetaDraft> = {}): WorktreeMetaDraft {
return {
displayNameInput: 'Workspace',
issueInput: '',
issueProvider: 'github',
prInput: '',
commentInput: '',
...overrides
}
}
function makeSnapshot(overrides: Partial<WorktreeMetaSnapshot> = {}): WorktreeMetaSnapshot {
return {
displayName: 'Workspace',
issueInput: '',
issueProvider: 'github',
...overrides
}
}
/** Persistence raw-spreads updates, so a present-but-undefined key erases the
* stored value — the invariant is asserted on every build in this suite. */
function buildUpdates(
draft: Partial<WorktreeMetaDraft>,
snapshot: Partial<WorktreeMetaSnapshot> = {}
): Partial<WorktreeMeta> {
const updates = buildWorktreeMetaUpdates(makeDraft(draft), makeSnapshot(snapshot))
const undefinedKeys = Object.keys(updates).filter(
(key) => updates[key as keyof WorktreeMeta] === undefined
)
expect(undefinedKeys).toEqual([])
return updates
}
const LINEAR_LINK_KEYS = [
'linkedLinearIssue',
'linkedLinearIssueWorkspaceId',
'linkedLinearIssueOrganizationUrlKey'
] as const
describe('buildWorktreeMetaUpdates', () => {
it('rejects issue URLs in the PR input', () => {
expect(
buildWorktreeMetaUpdates({
displayNameInput: 'Workspace',
currentDisplayName: 'Workspace',
issueInput: '',
prInput: 'https://github.com/stablyai/orca/issues/6933',
commentInput: ''
})
).toEqual({
// The dialog opens focused on Comment, so this is the common save path; a
// regression here silently destroys the user's existing link.
it('emits no link keys when the issue field is untouched', () => {
const updates = buildUpdates(
{ issueInput: 'STA-335', issueProvider: 'linear', commentInput: 'shipping today' },
{ issueInput: 'STA-335', issueProvider: 'linear' }
)
expect(updates.comment).toBe('shipping today')
expect(updates).toHaveProperty('linkedPR', null)
expect(updates).not.toHaveProperty('linkedIssue')
for (const key of LINEAR_LINK_KEYS) {
expect(updates).not.toHaveProperty(key)
}
})
it('writes a GitHub issue number and clears the Linear slots', () => {
expect(buildUpdates({ issueInput: '12' })).toEqual({
comment: '',
linkedIssue: null
linkedIssue: 12,
linkedLinearIssue: null,
linkedLinearIssueWorkspaceId: null,
linkedLinearIssueOrganizationUrlKey: null,
linkedPR: null
})
})
it('accepts PR URLs in the PR input', () => {
it('writes a bare Linear identifier and clears the stored organization key', () => {
expect(buildUpdates({ issueInput: 'sta-335', issueProvider: 'linear' })).toEqual({
comment: '',
linkedIssue: null,
linkedLinearIssue: 'STA-335',
linkedLinearIssueWorkspaceId: null,
linkedLinearIssueOrganizationUrlKey: null,
linkedPR: null
})
})
it('takes the organization key from a Linear issue URL', () => {
expect(
buildWorktreeMetaUpdates({
displayNameInput: 'Workspace',
currentDisplayName: 'Workspace',
issueInput: '',
prInput: 'https://github.com/stablyai/orca/pull/6934',
commentInput: ''
buildUpdates({
issueInput: 'https://linear.app/acme/issue/STA-335/fix-auth',
issueProvider: 'linear'
})
).toEqual({
comment: '',
linkedIssue: null,
linkedPR: 6934
linkedLinearIssue: 'STA-335',
linkedLinearIssueWorkspaceId: null,
linkedLinearIssueOrganizationUrlKey: 'acme',
linkedPR: null
})
})
it('accepts issue URLs in the issue input', () => {
expect(
buildWorktreeMetaUpdates({
displayNameInput: 'Workspace',
currentDisplayName: 'Workspace',
issueInput: 'https://github.com/stablyai/orca/issues/6933',
prInput: '',
commentInput: ''
})
).toEqual({
it('clears every provider slot when the issue field is emptied', () => {
expect(buildUpdates({ issueInput: ' ' }, { issueInput: '42' })).toEqual({
comment: '',
linkedIssue: 6933,
linkedIssue: null,
linkedLinearIssue: null,
linkedLinearIssueWorkspaceId: null,
linkedLinearIssueOrganizationUrlKey: null,
linkedPR: null
})
})
it('treats a provider switch with unchanged text as dirty', () => {
const updates = buildUpdates(
{ issueInput: 'STA-335', issueProvider: 'linear' },
{ issueInput: 'STA-335', issueProvider: 'github' }
)
expect(updates.linkedLinearIssue).toBe('STA-335')
expect(updates.linkedIssue).toBeNull()
})
it('displaces a Linear linked work item when the issue field changes', () => {
const updates = buildUpdates(
{ issueInput: '12' },
{ linkedWorkItemProvider: 'linear', linkedWorkItemType: 'issue' }
)
expect(updates).toHaveProperty('linkedWorkItem', null)
expect(updates).toHaveProperty('linkedTaskSourceContext', null)
})
// linkedWorkItem also records the PR or MR a workspace was created from, which
// the Issue row does not own and must not drop.
it('leaves a PR-typed work item alone', () => {
const updates = buildUpdates(
{ issueInput: '12' },
{ linkedWorkItemProvider: 'github', linkedWorkItemType: 'pr' }
)
expect(updates).not.toHaveProperty('linkedWorkItem')
expect(updates).not.toHaveProperty('linkedTaskSourceContext')
})
it('leaves work items owned by other providers alone', () => {
for (const provider of ['jira', 'gitlab'] as const) {
const updates = buildUpdates(
{ issueInput: '12' },
{ linkedWorkItemProvider: provider, linkedWorkItemType: 'issue' }
)
expect(updates).not.toHaveProperty('linkedWorkItem')
expect(updates).not.toHaveProperty('linkedTaskSourceContext')
}
})
// The row cannot render a GitLab or Jira issue, so clearing one would destroy a
// link the user was never shown — and neither has another editor to restore it.
it('leaves a GitLab issue attached', () => {
expect(buildUpdates({ issueInput: '12' })).not.toHaveProperty('linkedGitLabIssue')
expect(
buildUpdates({ issueInput: '12' }, { linkedWorkItemProvider: 'gitlab' })
).not.toHaveProperty('linkedGitLabIssue')
})
it('ignores a provider switch on an empty field', () => {
const updates = buildUpdates(
{ issueInput: '', issueProvider: 'linear' },
{ issueInput: '', issueProvider: 'github' }
)
expect(updates).not.toHaveProperty('linkedIssue')
for (const key of LINEAR_LINK_KEYS) {
expect(updates).not.toHaveProperty(key)
}
})
it('does not displace a Linear work item when the issue field is clean', () => {
const updates = buildUpdates({ commentInput: 'note' }, { linkedWorkItemProvider: 'linear' })
expect(updates).not.toHaveProperty('linkedWorkItem')
expect(updates).not.toHaveProperty('linkedTaskSourceContext')
})
it('leaves links untouched for unparseable issue input', () => {
const updates = buildUpdates(
{ issueInput: 'not an issue', displayNameInput: 'Renamed', commentInput: 'note' },
{ displayName: 'Workspace' }
)
expect(updates).toEqual({
comment: 'note',
displayName: 'Renamed',
linkedPR: null
})
})
it('clears a display name with empty string, never a present-undefined key', () => {
const updates = buildWorktreeMetaUpdates({
displayNameInput: ' ',
currentDisplayName: 'Custom Name',
issueInput: '',
prInput: '',
commentInput: ''
})
const updates = buildUpdates({ displayNameInput: ' ' }, { displayName: 'Custom Name' })
expect(updates.displayName).toBe('')
expect(Object.values(updates).every((value) => value !== undefined)).toBe(true)
})
it('rejects issue URLs in the PR input', () => {
expect(buildUpdates({ prInput: 'https://github.com/stablyai/orca/issues/6933' })).toEqual({
comment: ''
})
})
it('accepts PR URLs in the PR input', () => {
expect(buildUpdates({ prInput: 'https://github.com/stablyai/orca/pull/6934' })).toEqual({
comment: '',
linkedPR: 6934
})
})
it('accepts issue URLs in the issue input', () => {
expect(buildUpdates({ issueInput: 'https://github.com/stablyai/orca/issues/6933' })).toEqual({
comment: '',
linkedIssue: 6933,
linkedLinearIssue: null,
linkedLinearIssueWorkspaceId: null,
linkedLinearIssueOrganizationUrlKey: null,
linkedPR: null
})
})
it('rejects PR URLs in the issue input', () => {
expect(
buildWorktreeMetaUpdates({
displayNameInput: 'Workspace',
currentDisplayName: 'Workspace',
issueInput: 'https://github.com/stablyai/orca/pull/6934',
prInput: '',
commentInput: ''
})
).toEqual({
expect(buildUpdates({ issueInput: 'https://github.com/stablyai/orca/pull/6934' })).toEqual({
comment: '',
linkedPR: null
})
@@ -1,11 +1,38 @@
import { parseGitHubIssueOrPRLink, parseGitHubIssueOrPRNumber } from '@/lib/github-links'
import type { WorktreeMeta } from '../../../../shared/types'
import {
buildLinearIssueLinkUpdates,
LINEAR_ISSUE_LINK_CLEARED
} from '../../../../shared/linear-links'
import { parseIssueLinkInput, type IssueLinkProvider } from '../../../../shared/issue-link-input'
import type { WorkspaceSourceProvider } from '../../../../shared/new-workspace/workspace-source'
import type { WorkspaceLinkedItem, WorktreeMeta } from '../../../../shared/types'
export type WorktreeMetaSavedPayload = {
worktreeId: string
updates: Partial<WorktreeMeta>
}
/** What the user currently has typed in the dialog. */
export type WorktreeMetaDraft = {
displayNameInput: string
issueInput: string
issueProvider: IssueLinkProvider
prInput: string
commentInput: string
}
/** The persisted state the dialog was seeded from. Captured once when the
* dialog opens: comparing a frozen draft against a live store would let a
* background write move the baseline and make an untouched field "dirty". */
export type WorktreeMetaSnapshot = {
displayName: string
issueInput: string
issueProvider: IssueLinkProvider
linkedWorkItemProvider?: WorkspaceSourceProvider | null
/** `linkedWorkItem` also describes PRs and MRs, which this row does not own. */
linkedWorkItemType?: WorkspaceLinkedItem['type'] | null
}
export function parseExplicitGitHubIssueUrl(input: string): string | null {
const trimmed = input.trim()
const link = parseGitHubIssueOrPRLink(trimmed)
@@ -30,38 +57,104 @@ export function parseGitHubWorkItemNumberForMetaField(
return parseGitHubIssueOrPRNumber(input)
}
/** Pure save-payload builder for the worktree meta dialog: empty inputs clear
* the link (null), unparseable inputs leave it untouched (omitted). */
export function buildWorktreeMetaUpdates(args: {
displayNameInput: string
currentDisplayName: string
issueInput: string
prInput: string
commentInput: string
}): Partial<WorktreeMeta> {
const trimmedIssue = args.issueInput.trim()
const linkedIssueNumber = parseGitHubWorkItemNumberForMetaField(trimmedIssue, 'issue')
const finalLinkedIssue =
trimmedIssue === '' ? null : linkedIssueNumber !== null ? linkedIssueNumber : undefined
const trimmedPR = args.prInput.trim()
const linkedPRNumber = parseGitHubWorkItemNumberForMetaField(trimmedPR, 'pr')
const finalLinkedPR =
trimmedPR === '' ? null : linkedPRNumber !== null ? linkedPRNumber : undefined
// Why: blanking the field means "fall back to the branch/folder name", and the
// empty string is how that intent is persisted. Emitting `undefined` instead
// put a present-but-undefined key into the store spread, wiping the live name
// and crashing the worktree palette (crash a1f81ea1).
const trimmedDisplayName = args.displayNameInput.trim()
const updates: Partial<WorktreeMeta> = {
comment: args.commentInput.trim(),
...(trimmedDisplayName !== args.currentDisplayName && { displayName: trimmedDisplayName })
// Why: blanking the field means "fall back to the branch/folder name", and the
// empty string is how that intent is persisted. Emitting `undefined` instead
// put a present-but-undefined key into the store spread, wiping the live name
// and crashing the worktree palette (crash a1f81ea1).
function buildDisplayNameUpdate(
draft: WorktreeMetaDraft,
current: WorktreeMetaSnapshot
): Partial<WorktreeMeta> {
const trimmed = draft.displayNameInput.trim()
return trimmed === current.displayName ? {} : { displayName: trimmed }
}
export function isIssueFieldDirty(
draft: WorktreeMetaDraft,
current: WorktreeMetaSnapshot
): boolean {
if (draft.issueInput.trim() !== current.issueInput.trim()) {
return true
}
// Why: a provider switch only means something when there is a value to
// reinterpret. Toggling the chip on an empty field states no intent, and
// treating it as dirty would turn a comment-only save into a link wipe.
return draft.issueProvider !== current.issueProvider && draft.issueInput.trim() !== ''
}
/** Owns both provider slot families. One issue per workspace: writing one
* provider clears the other. Emits nothing at all unless the field changed —
* the dialog opens focused on Comment, so an untouched field must never
* destroy a link the user came here to keep. */
function buildIssueLinkUpdates(
draft: WorktreeMetaDraft,
current: WorktreeMetaSnapshot
): Partial<WorktreeMeta> {
if (!isIssueFieldDirty(draft, current)) {
return {}
}
const trimmed = draft.issueInput.trim()
// Why: the linked work item and its source context describe the issue being
// replaced. Leaving them would keep a stale title badge and mis-scope Linear
// reads. Narrow on purpose: `type` because the field also records the PR or MR
// a workspace was created from, and provider because GitLab and Jira issues
// have no slot in this row — displacing what it cannot display would destroy a
// link the user was never shown and has no other editor to restore it from.
const displacedWorkItem: Partial<WorktreeMeta> =
(current.linkedWorkItemProvider === 'github' || current.linkedWorkItemProvider === 'linear') &&
current.linkedWorkItemType === 'issue'
? { linkedWorkItem: null, linkedTaskSourceContext: null }
: {}
if (trimmed === '') {
return {
linkedIssue: null,
...LINEAR_ISSUE_LINK_CLEARED,
...displacedWorkItem
}
}
const parsed = parseIssueLinkInput(trimmed, draft.issueProvider)
if (!parsed) {
// Why: unparseable input leaves every link untouched. `canSave` already
// blocks this path, but the builder stays pure rather than relying on it.
return {}
}
if (finalLinkedIssue !== undefined) {
updates.linkedIssue = finalLinkedIssue
if (parsed.provider === 'github') {
return {
linkedIssue: parsed.number,
...LINEAR_ISSUE_LINK_CLEARED,
...displacedWorkItem
}
}
if (finalLinkedPR !== undefined) {
updates.linkedPR = finalLinkedPR
const linearUpdates = buildLinearIssueLinkUpdates(trimmed)
return linearUpdates ? { linkedIssue: null, ...linearUpdates, ...displacedWorkItem } : {}
}
function buildPrLinkUpdate(draft: WorktreeMetaDraft): Partial<WorktreeMeta> {
const trimmed = draft.prInput.trim()
if (trimmed === '') {
return { linkedPR: null }
}
const number = parseGitHubWorkItemNumberForMetaField(trimmed, 'pr')
return number === null ? {} : { linkedPR: number }
}
/** Pure save-payload builder for the worktree meta dialog: empty inputs clear
* the link (null), unparseable inputs leave it untouched (omitted). No key is
* ever emitted holding `undefined` — persistence raw-spreads updates, so a
* present-but-undefined key erases the stored value. */
export function buildWorktreeMetaUpdates(
draft: WorktreeMetaDraft,
current: WorktreeMetaSnapshot
): Partial<WorktreeMeta> {
return {
comment: draft.commentInput.trim(),
...buildDisplayNameUpdate(draft, current),
...buildIssueLinkUpdates(draft, current),
...buildPrLinkUpdate(draft)
}
return updates
}
+19 -1
View File
@@ -270,6 +270,7 @@
"4a18052018": "Local {{value0}} is behind {{value1}}",
"903b51c2ed": "Workspace created from {{value0}}, but Orca could not fast-forward local {{value1}} because {{value2}}",
"0216895fb5": "Failed to delete branch",
"c6cf133786": "This workspace is no longer available.",
"19db0085fb": "Local branch deleted",
"2b0afc7f14": "Could not keep local {{value0}} up to date",
"670864ab52": "Keeping local {{value0}} up to date",
@@ -4688,7 +4689,8 @@
"65770ad0f0": "Edit GitHub links and notes for this workspace.",
"382fd11a3e": "Edit Worktree Details",
"2174f17011": "Save",
"61d6f612cf": "Saving..."
"61d6f612cf": "Saving...",
"a0d191b7a7": "Edit issue links, pull request links, and notes for this workspace."
},
"WorktreeOpenInMenu": {
"localOnly": "Local only",
@@ -5124,6 +5126,22 @@
"3b7ea51793": "Clear search",
"7f1c2e94a5": "Search text is too long — the board is unfiltered",
"9a4d0f6b21": "Too long"
},
"WorktreeIssueLinkField": {
"25852bfc59": "Linear",
"5b440069e6": "GitHub",
"161b2d053a": "Open linked issue",
"d4785f9954": "Issue links are set when a folder workspace is created and can't be changed here yet.",
"964d9bc00a": "Not a Linear issue key or linear.app issue URL.",
"0a7a2c6efd": "Not a GitHub issue number or issue URL.",
"72486800ff": "Saving unlinks {{first}} and {{second}} — a workspace tracks one issue.",
"2c245ac134": "Saving unlinks {{link}} — a workspace tracks one issue.",
"d8c8a30d1f": "Couldn't open that issue. Check the identifier and your Linear connection.",
"269198eeda": "Couldn't open that issue. Check the number and your GitHub connection.",
"f047887705": "Paste a GitHub or Linear URL, or enter a number. Leave blank to remove the link.",
"ad78f9bee2": "Issue",
"662ae142f8": "Issue #, or a GitHub or Linear URL",
"929c98d05a": "Issue provider"
}
},
"shared": {
@@ -236,11 +236,13 @@ export type WorktreeSlice = {
expectedHead: string
) => Promise<({ ok: true } & ForceDeleteWorktreeBranchResult) | { ok: false; error: string }>
clearWorktreeDeleteState: (worktreeId: string) => void
/** Never rejects — most callers fire-and-forget. Callers that own a surface
* the user is waiting on should read the result and say what went wrong. */
updateWorktreeMeta: (
worktreeId: string,
updates: Partial<WorktreeMeta>,
options?: WorktreeMetaUpdateOptions
) => Promise<void>
) => Promise<{ ok: true } | { ok: false; error: string }>
ensureHostedReviewPushTarget: (worktreeId: string) => Promise<void>
updateWorktreesMeta: (
updatesByWorktreeId: ReadonlyMap<string, Partial<WorktreeMeta>>
+33 -10
View File
@@ -50,7 +50,10 @@ import {
isRuntimeScopeForbiddenError,
RuntimeRpcCallError
} from '../../runtime/runtime-rpc-client'
import { WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import {
TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY,
WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY
} from '../../../../shared/protocol-version'
import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector'
import { getHostedReviewCacheKey, refreshHostedReviewCard } from './hosted-review'
import { routeListingBranchSwitchesThroughGitIdentity } from './worktree-listing-branch-switch'
@@ -1710,16 +1713,26 @@ async function persistWorktreeMeta(
await window.api.worktrees.updateMeta({ worktreeId, updates })
return
}
// Why: same gate as worktree.create — an older paired runtime would drop the Jira link silently.
// Why: `worktree.set` parses in strip mode, so an older runtime drops the key
// and applies the rest. Both gates key off presence, not value — a dropped
// *clear* strands a stale link that the Issue row then hides.
if (
target.kind === 'environment' &&
(updates.linkedWorkItem?.provider === 'jira' ||
updates.linkedTaskSourceContext?.provider === 'jira')
('linkedWorkItem' in updates || 'linkedTaskSourceContext' in updates)
) {
await assertRuntimeEnvironmentCapability(
target.environmentId,
WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY,
'Update the remote runtime to link Jira'
'Update the remote runtime to change this workspace’s linked issue'
)
}
// task-source-context.v1 is a sound proxy for the Linear keys: #5322 added them
// to the schema and is an ancestor of the commit introducing that capability.
if (target.kind === 'environment' && 'linkedLinearIssue' in updates) {
await assertRuntimeEnvironmentCapability(
target.environmentId,
TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY,
'Update the remote runtime to link Linear issues'
)
}
await callRuntimeRpc(
@@ -4497,7 +4510,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
const shouldApplyUpdate = options?.shouldApply
const existingWorktree = get().getKnownWorktreeById(worktreeId)
if (shouldApplyUpdate && !shouldApplyUpdate(existingWorktree)) {
return
return { ok: true }
}
const workspaceScope = parseWorkspaceKey(worktreeId)
if (workspaceScope?.type === 'folder') {
@@ -4505,7 +4518,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
if (Object.keys(folderUpdates).length > 0) {
await get().updateFolderWorkspace(workspaceScope.folderWorkspaceId, folderUpdates)
}
return
return { ok: true }
}
const normalizedUpdates = existingWorktree
? clearOlderHostedReviewLinksForReplacement(updates, existingWorktree)
@@ -4540,7 +4553,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
existingHostedReviewPushTargetLookup.key !== nextHostedReviewPushTargetLookup?.key
const worktreeForUpdate = get().getKnownWorktreeById(worktreeId)
if (shouldApplyUpdate && !shouldApplyUpdate(worktreeForUpdate)) {
return
return { ok: true }
}
const shouldRefreshHostedReview =
(normalizedUpdates.linkedPR === null && worktreeForUpdate?.linkedPR !== null) ||
@@ -4661,7 +4674,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
}
})
if (shouldApplyUpdate && !didApply) {
return
return { ok: true }
}
if (hasHostedReviewLinkUpdates(enriched)) {
bumpHostedReviewLinkMutationGeneration(worktreeId)
@@ -4709,11 +4722,21 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
} catch (err) {
if (isRuntimeSelectorNotFoundError(err)) {
void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId))
return
return {
ok: false,
error: translate(
'auto.store.slices.worktrees.c6cf133786',
'This workspace is no longer available.'
)
}
}
console.error('Failed to update worktree meta:', err)
void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId))
// Why: the refetch above reverts the optimistic write, so a caller that
// closes its surface on this path shows the user a save that undid itself.
return { ok: false, error: err instanceof Error ? err.message : String(err) }
}
return { ok: true }
},
ensureHostedReviewPushTarget: async (worktreeId) => {
+112
View File
@@ -0,0 +1,112 @@
import { describe, expect, it } from 'vitest'
import { getIssueLinkProviderFromUrl, parseIssueLinkInput } from './issue-link-input'
describe('getIssueLinkProviderFromUrl', () => {
it('detects GitHub issue URLs', () => {
expect(getIssueLinkProviderFromUrl('https://github.com/o/r/issues/12')).toBe('github')
})
it('does not flip the provider for a GitHub pull URL', () => {
expect(getIssueLinkProviderFromUrl('https://github.com/o/r/pull/12')).toBeNull()
})
it('detects Linear issue URLs with and without a slug', () => {
expect(getIssueLinkProviderFromUrl('https://linear.app/acme/issue/STA-335')).toBe('linear')
expect(getIssueLinkProviderFromUrl('https://linear.app/acme/issue/STA-335/some-slug')).toBe(
'linear'
)
})
it('ignores non-issue paths on the Linear host', () => {
expect(getIssueLinkProviderFromUrl('https://linear.app/acme/team/ENG/all')).toBeNull()
})
it('rejects hosts that merely contain linear.app', () => {
expect(getIssueLinkProviderFromUrl('https://linear.app.evil.com/acme/issue/STA-335')).toBeNull()
})
// Linear and Jira issue-key shapes are byte-identical, so a bare key must
// never override the user's explicit provider choice.
it('is not decisive for bare issue keys', () => {
expect(getIssueLinkProviderFromUrl('STA-335')).toBeNull()
expect(getIssueLinkProviderFromUrl(' STA-335 ')).toBeNull()
})
it('is not decisive for bare numbers', () => {
expect(getIssueLinkProviderFromUrl('1234')).toBeNull()
expect(getIssueLinkProviderFromUrl('#1234')).toBeNull()
})
it('returns null for empty input', () => {
expect(getIssueLinkProviderFromUrl('')).toBeNull()
expect(getIssueLinkProviderFromUrl(' ')).toBeNull()
})
})
describe('parseIssueLinkInput', () => {
describe('github provider', () => {
it('accepts bare and hash-prefixed numbers', () => {
expect(parseIssueLinkInput('12', 'github')).toEqual({ provider: 'github', number: 12 })
expect(parseIssueLinkInput('#12', 'github')).toEqual({ provider: 'github', number: 12 })
})
it('accepts issue URLs', () => {
expect(parseIssueLinkInput('https://github.com/o/r/issues/12', 'github')).toEqual({
provider: 'github',
number: 12
})
})
it('rejects pull URLs', () => {
expect(parseIssueLinkInput('https://github.com/o/r/pull/12', 'github')).toBeNull()
})
it('rejects non-positive numbers and junk', () => {
expect(parseIssueLinkInput('0', 'github')).toBeNull()
expect(parseIssueLinkInput('-1', 'github')).toBeNull()
expect(parseIssueLinkInput('not an issue', 'github')).toBeNull()
expect(parseIssueLinkInput(' ', 'github')).toBeNull()
})
it('rejects Linear identifiers', () => {
expect(parseIssueLinkInput('STA-335', 'github')).toBeNull()
})
})
describe('linear provider', () => {
it('accepts bare identifiers and normalizes case', () => {
expect(parseIssueLinkInput('STA-335', 'linear')).toEqual({
provider: 'linear',
identifier: 'STA-335'
})
expect(parseIssueLinkInput('sta-335', 'linear')).toEqual({
provider: 'linear',
identifier: 'STA-335'
})
})
it('omits the organization key for bare identifiers', () => {
expect(parseIssueLinkInput('STA-335', 'linear')).not.toHaveProperty('organizationUrlKey')
})
it('accepts issue URLs and returns the organization key', () => {
expect(
parseIssueLinkInput('https://linear.app/acme/issue/STA-335/some-slug', 'linear')
).toEqual({
provider: 'linear',
identifier: 'STA-335',
organizationUrlKey: 'acme'
})
})
it('rejects GitHub URLs', () => {
expect(parseIssueLinkInput('https://github.com/o/r/issues/12', 'linear')).toBeNull()
})
it('rejects junk and empty input', () => {
expect(parseIssueLinkInput('not an issue', 'linear')).toBeNull()
expect(parseIssueLinkInput(' ', 'linear')).toBeNull()
})
})
})
+75
View File
@@ -0,0 +1,75 @@
import { parseGitHubIssueOrPRLink } from './github-links'
import { parseLinearIssueInput } from './linear-links'
import type { WorkspaceSourceProvider } from './new-workspace/workspace-source'
// Why: narrows the canonical provider union instead of minting a parallel one,
// so adding Jira here is a one-entry change rather than a new axis.
export const ISSUE_LINK_PROVIDERS = [
'github',
'linear'
] as const satisfies readonly WorkspaceSourceProvider[]
export type IssueLinkProvider = (typeof ISSUE_LINK_PROVIDERS)[number]
export function isIssueLinkProvider(value: unknown): value is IssueLinkProvider {
return ISSUE_LINK_PROVIDERS.includes(value as IssueLinkProvider)
}
/** URL input only. Linear and Jira issue keys are byte-identical in shape, so a
* bare `STA-335` can never decide a provider — it would override the chip. */
export function getIssueLinkProviderFromUrl(input: string): IssueLinkProvider | null {
const trimmed = input.trim()
if (!/^https?:\/\//i.test(trimmed)) {
return null
}
// Why: a URL is decisive only if it names that provider's *issue*. A GitHub
// pull URL is a valid GitHub link but not an issue, and must not flip the
// provider into a state where the field then refuses to save it.
if (parseGitHubIssueOrPRLink(trimmed)?.type === 'issue') {
return 'github'
}
if (parseLinearIssueInput(trimmed)) {
return 'linear'
}
return null
}
export type ParsedIssueLinkInput =
| { provider: 'github'; number: number }
| { provider: 'linear'; identifier: string; organizationUrlKey?: string }
/**
* Single parse shared by the dialog's save gate and its payload builder, so a
* value that enables Save is always the value that gets written.
*/
export function parseIssueLinkInput(
input: string,
provider: IssueLinkProvider
): ParsedIssueLinkInput | null {
const trimmed = input.trim()
if (!trimmed) {
return null
}
if (provider === 'linear') {
const parsed = parseLinearIssueInput(trimmed)
return parsed ? { provider: 'linear', ...parsed } : null
}
const link = parseGitHubIssueOrPRLink(trimmed)
if (link) {
// Why: issue and PR numbers live in separate GitHub namespaces for refs; a
// pull URL pasted here must not silently become an issue link.
return link.type === 'issue' ? { provider: 'github', number: link.number } : null
}
const numeric = trimmed.startsWith('#') ? trimmed.slice(1) : trimmed
if (!/^\d+$/.test(numeric)) {
return null
}
const parsed = Number.parseInt(numeric, 10)
// Why: `/^\d+$/` happily accepts 400 digits, which parseInt turns into
// Infinity — Save would enable and JSON.stringify would persist `null`.
return Number.isSafeInteger(parsed) && parsed > 0 ? { provider: 'github', number: parsed } : null
}
+81
View File
@@ -1,6 +1,8 @@
import { describe, expect, it } from 'vitest'
import {
buildLinearIssueLinkUpdates,
buildLinearIssueUrl,
buildLinearPersonalApiKeySettingsUrl,
buildLinearTeamUrl,
buildLinearWorkspaceApiSettingsUrl,
@@ -62,4 +64,83 @@ describe('linear links', () => {
expect(parseLinearIssueInput('https://example.com/acme/issue/ENG-123')).toBeNull()
expect(parseLinearIssueInput('not an issue')).toBeNull()
})
// A hostname-only check accepts these, and the parsed link is later handed to
// shell.openUrl — so the protocol gate is load-bearing, not cosmetic.
it('rejects linear.app URLs on non-web protocols', () => {
for (const input of [
'file://linear.app/acme/issue/ENG-123',
'javascript://linear.app/acme/issue/ENG-123',
'ftp://linear.app/acme/issue/ENG-123'
]) {
expect(parseLinearIssueInput(input)).toBeNull()
}
})
})
describe('buildLinearIssueUrl', () => {
it('builds issue URLs from an identifier and organization key', () => {
expect(buildLinearIssueUrl({ identifier: 'STA-335', organizationUrlKey: 'acme' })).toBe(
'https://linear.app/acme/issue/STA-335'
)
})
it('returns null when either part is missing or blank', () => {
expect(buildLinearIssueUrl({ identifier: 'STA-335' })).toBeNull()
expect(buildLinearIssueUrl({ organizationUrlKey: 'acme' })).toBeNull()
expect(buildLinearIssueUrl({ identifier: ' ', organizationUrlKey: 'acme' })).toBeNull()
expect(buildLinearIssueUrl({ identifier: 'STA-335', organizationUrlKey: null })).toBeNull()
})
it('encodes URL-unsafe path segments', () => {
expect(buildLinearIssueUrl({ identifier: 'A/B-1', organizationUrlKey: 'acme inc' })).toBe(
'https://linear.app/acme%20inc/issue/A%2FB-1'
)
})
})
describe('buildLinearIssueLinkUpdates', () => {
function expectNoUndefinedValues(updates: Record<string, unknown>): void {
expect(Object.keys(updates).filter((key) => updates[key] === undefined)).toEqual([])
}
it('clears every link field for empty input', () => {
for (const input of ['', ' ']) {
const result = buildLinearIssueLinkUpdates(input)
expect(result).toEqual({
linkedLinearIssue: null,
linkedLinearIssueWorkspaceId: null,
linkedLinearIssueOrganizationUrlKey: null
})
}
})
// Inheriting the previous issue's org key would let resolveLegacyLinearLinkWorkspace
// match it and persist a workspace id the new issue does not belong to.
it('clears the organization key for bare identifiers', () => {
const result = buildLinearIssueLinkUpdates('STA-335')
expect(result).toEqual({
linkedLinearIssue: 'STA-335',
linkedLinearIssueWorkspaceId: null,
linkedLinearIssueOrganizationUrlKey: null
})
expectNoUndefinedValues(result as Record<string, unknown>)
})
it('takes the organization key from a Linear issue URL', () => {
const result = buildLinearIssueLinkUpdates('https://linear.app/acme/issue/sta-335/fix-auth')
expect(result).toEqual({
linkedLinearIssue: 'STA-335',
linkedLinearIssueWorkspaceId: null,
linkedLinearIssueOrganizationUrlKey: 'acme'
})
expectNoUndefinedValues(result as Record<string, unknown>)
})
it('returns null for unparseable input', () => {
expect(buildLinearIssueLinkUpdates('not an issue')).toBeNull()
expect(buildLinearIssueLinkUpdates('https://github.com/o/r/issues/12')).toBeNull()
})
})
+52
View File
@@ -24,6 +24,18 @@ export function buildLinearWorkspaceApiSettingsUrl(organizationUrlKey?: string |
: 'https://linear.app/settings/api'
}
export function buildLinearIssueUrl(args: {
identifier?: string | null
organizationUrlKey?: string | null
}): string | null {
const identifier = args.identifier?.trim()
const organizationUrlKey = args.organizationUrlKey?.trim()
if (!identifier || !organizationUrlKey) {
return null
}
return `https://linear.app/${encodeURIComponent(organizationUrlKey)}/issue/${encodeURIComponent(identifier)}`
}
export function getLinearOrganizationUrlKeyFromIssueUrl(issueUrl?: string | null): string | null {
if (!issueUrl) {
return null
@@ -44,6 +56,12 @@ export type ParsedLinearIssueInput = {
organizationUrlKey?: string
}
export type LinearIssueLinkUpdates = {
linkedLinearIssue: string | null
linkedLinearIssueWorkspaceId: string | null
linkedLinearIssueOrganizationUrlKey: string | null
}
const LINEAR_IDENTIFIER_PATTERN = /^[A-Za-z][A-Za-z0-9_]*-\d+$/
export function parseLinearIssueInput(input: string): ParsedLinearIssueInput | null {
@@ -58,6 +76,12 @@ export function parseLinearIssueInput(input: string): ParsedLinearIssueInput | n
try {
const parsed = new URL(trimmed)
// Why: hostname alone accepts file://linear.app/..., which then passes
// validation and persists as a link. The GitHub parser already gates on
// protocol; this keeps the two in step.
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
return null
}
if (parsed.hostname !== 'linear.app') {
return null
}
@@ -80,3 +104,31 @@ export function parseLinearIssueInput(input: string): ParsedLinearIssueInput | n
return null
}
}
export const LINEAR_ISSUE_LINK_CLEARED: LinearIssueLinkUpdates = {
linkedLinearIssue: null,
linkedLinearIssueWorkspaceId: null,
linkedLinearIssueOrganizationUrlKey: null
}
/** Shared by the CLI and the meta dialog so both persist identical state for
* identical input. Returns null when the input is neither empty nor parseable. */
export function buildLinearIssueLinkUpdates(input: string): LinearIssueLinkUpdates | null {
if (input.trim() === '') {
return { ...LINEAR_ISSUE_LINK_CLEARED }
}
const parsed = parseLinearIssueInput(input)
if (!parsed) {
return null
}
return {
linkedLinearIssue: parsed.identifier,
// Both scoping fields describe the issue being REPLACED. A stale org key
// pins resolveLegacyLinearLinkWorkspace to the wrong org — not-found, or a
// silently wrong issue on a prefix collision. Nulling only costs a re-read.
linkedLinearIssueWorkspaceId: null,
linkedLinearIssueOrganizationUrlKey: parsed.organizationUrlKey ?? null
}
}