diff --git a/src/cli/handlers/worktree-linear-issue-link.ts b/src/cli/handlers/worktree-linear-issue-link.ts new file mode 100644 index 000000000..2285da4f0 --- /dev/null +++ b/src/cli/handlers/worktree-linear-issue-link.ts @@ -0,0 +1,63 @@ +import { parseLinearIssueInput } 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, + name: string, + options: { allowNull?: boolean } = {} +): LinearIssueLinkParams | undefined { + const value = getPresentStringFlag(flags, name) + if (value === undefined) { + return undefined + } + + if (value.trim().toLowerCase() === 'null') { + if (!options.allowNull) { + throw new RuntimeClientError( + 'invalid_argument', + 'Omit --linear-issue on create, or pass a Linear issue identifier or URL.' + ) + } + return { + linkedLinearIssue: null, + linkedLinearIssueWorkspaceId: null, + linkedLinearIssueOrganizationUrlKey: null + } + } + + const parsed = parseLinearIssueInput(value) + if (!parsed) { + 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 + } +} + +function getPresentStringFlag( + flags: Map, + name: string +): string | undefined { + if (!flags.has(name)) { + return undefined + } + const value = flags.get(name) + if (typeof value === 'string' && value.length > 0) { + return value + } + throw new RuntimeClientError('invalid_argument', `Missing value for --${name}`) +} diff --git a/src/cli/handlers/worktree.ts b/src/cli/handlers/worktree.ts index b70a9fd21..1ee5a3b37 100644 --- a/src/cli/handlers/worktree.ts +++ b/src/cli/handlers/worktree.ts @@ -21,6 +21,7 @@ import { resolveCurrentWorktreeSelector } from '../selectors' import { isTuiAgent } from '../../shared/tui-agent-config' +import { getOptionalLinearIssueLinkFlag } from './worktree-linear-issue-link' type HookWarningResult = { warning?: string @@ -219,11 +220,13 @@ export const WORKTREE_HANDLERS: Record = { cwdParentWorktree = undefined } } + const linearIssueLink = getOptionalLinearIssueLinkFlag(flags, 'linear-issue') const result = await client.call('worktree.create', { repo: getCreateRepoSelector(flags, cwdParentWorktree), name: getRequiredStringFlag(flags, 'name'), baseBranch: getOptionalStringFlag(flags, 'base-branch'), linkedIssue: getOptionalNumberFlag(flags, 'issue'), + ...linearIssueLink, comment: getOptionalStringFlag(flags, 'comment'), runHooks: flags.get('run-hooks') === true, activate: @@ -246,10 +249,14 @@ export const WORKTREE_HANDLERS: Record = { }, 'worktree set': async ({ flags, client, cwd, json }) => { assertParentFlagsCompatible(flags) + const linearIssueLink = getOptionalLinearIssueLinkFlag(flags, 'linear-issue', { + allowNull: true + }) const result = await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.set', { worktree: await getRequiredWorktreeSelector(flags, 'worktree', cwd, client), displayName: getOptionalStringFlag(flags, 'display-name'), linkedIssue: getOptionalNullableNumberFlag(flags, 'issue'), + ...linearIssueLink, comment: getOptionalStringFlag(flags, 'comment'), workspaceStatus: getOptionalStringFlag(flags, 'workspace-status'), parentWorktree: await getOptionalWorktreeSelector(flags, 'parent-worktree', cwd, client), diff --git a/src/cli/help.ts b/src/cli/help.ts index 613eaa5a0..6445ac245 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -185,10 +185,10 @@ Common Commands: orca environment show --environment [--json] orca environment rm --environment [--json] orca worktree list [--repo ] [--limit ] [--json] - orca worktree create --name [--repo ] [--agent ] [--prompt ] [--setup run|skip|inherit] [--base-branch ] [--issue ] [--comment ] [--parent-worktree ] [--no-parent] [--run-hooks] [--activate] [--json] + orca worktree create --name [--repo ] [--agent ] [--prompt ] [--setup run|skip|inherit] [--base-branch ] [--issue ] [--linear-issue ] [--comment ] [--parent-worktree ] [--no-parent] [--run-hooks] [--activate] [--json] orca worktree show --worktree [--json] orca worktree current [--json] - orca worktree set --worktree [--display-name ] [--issue ] [--comment ] [--workspace-status ] [--parent-worktree |--no-parent] [--json] + orca worktree set --worktree [--display-name ] [--issue ] [--linear-issue ] [--comment ] [--workspace-status ] [--parent-worktree |--no-parent] [--json] orca worktree rm --worktree [--force] [--run-hooks] [--json] orca worktree ps [--limit ] [--json] orca file open [--worktree ] [--json] @@ -277,9 +277,12 @@ Examples: $ orca repo list $ orca worktree create --name agent-task --agent codex --prompt "hi" $ orca worktree create --repo name:orca --name cli-test-1 --issue 273 + $ orca worktree create --repo name:orca --name linear-task --linear-issue https://linear.app/stably/issue/STA-335/test-issue + $ orca worktree create --name linear-task --linear-issue STA-335 $ orca worktree show --worktree branch:Jinwoo-H/cli $ orca worktree current $ orca worktree set --worktree active --comment "waiting on review" + $ orca worktree set --worktree active --linear-issue null $ orca worktree ps --limit 10 $ orca file open-changed --mode diff $ orca file open src/App.tsx @@ -442,6 +445,8 @@ export function formatFlagHelp(flag: string): string { interrupt: '--interrupt Send as an interrupt-style input when supported', id: '--id Identifier for a target item or permission', issue: '--issue Linked GitHub issue number', + 'linear-issue': + '--linear-issue Linked Linear issue identifier or URL; null clears on set', json: '--json Emit machine-readable JSON', key: '--key Key argument for this command', limit: '--limit Maximum number of rows to return', diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 35a0ea91f..002fe5130 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -204,6 +204,23 @@ describe('orca root help', () => { expect(createHelp).toContain('--parent-current Use the current linked issue as parent') expect(callMock).not.toHaveBeenCalled() }) + + it('advertises Linear issue linking on worktree create and set help', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + logSpy.mockClear() + + await main(['worktree', 'create', '--help'], '/tmp/repo') + + expect(String(logSpy.mock.calls[0][0])).toContain('--linear-issue ') + + logSpy.mockClear() + await main(['worktree', 'set', '--help'], '/tmp/repo') + + const setHelp = String(logSpy.mock.calls[0][0]) + expect(setHelp).toContain('--linear-issue ') + expect(setHelp).toContain('--linear-issue Linked Linear issue identifier or URL') + expect(callMock).not.toHaveBeenCalled() + }) }) describe('orca cli worktree awareness', () => { @@ -628,6 +645,115 @@ describe('orca cli worktree awareness', () => { }) }) + it('passes Linear URL metadata through worktree.set', async () => { + queueFixtures( + callMock, + okFixture('req_set_linear', { + worktree: { + ...buildWorktree('/tmp/repo/child', 'feature/child'), + linkedLinearIssue: 'STA-335', + linkedLinearIssueWorkspaceId: null, + linkedLinearIssueOrganizationUrlKey: 'stably' + } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + [ + 'worktree', + 'set', + '--worktree', + 'id:repo::/tmp/repo/child', + '--linear-issue', + 'https://linear.app/stably/issue/STA-335/test-issue', + '--json' + ], + '/tmp/repo' + ) + + expect(callMock).toHaveBeenCalledWith('worktree.set', { + worktree: 'id:repo::/tmp/repo/child', + displayName: undefined, + linkedIssue: undefined, + linkedLinearIssue: 'STA-335', + linkedLinearIssueWorkspaceId: null, + linkedLinearIssueOrganizationUrlKey: 'stably', + comment: undefined, + workspaceStatus: undefined, + parentWorktree: undefined, + noParent: false + }) + }) + + it('clears all Linear metadata through worktree.set', async () => { + queueFixtures( + callMock, + okFixture('req_clear_linear', { + worktree: { + ...buildWorktree('/tmp/repo/child', 'feature/child'), + linkedLinearIssue: null, + linkedLinearIssueWorkspaceId: null, + linkedLinearIssueOrganizationUrlKey: null + } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + [ + 'worktree', + 'set', + '--worktree', + 'id:repo::/tmp/repo/child', + '--linear-issue', + 'null', + '--json' + ], + '/tmp/repo' + ) + + expect(callMock).toHaveBeenCalledWith('worktree.set', { + worktree: 'id:repo::/tmp/repo/child', + displayName: undefined, + linkedIssue: undefined, + linkedLinearIssue: null, + linkedLinearIssueWorkspaceId: null, + linkedLinearIssueOrganizationUrlKey: null, + comment: undefined, + workspaceStatus: undefined, + parentWorktree: undefined, + noParent: false + }) + }) + + it('rejects invalid Linear issue values on worktree.set before RPC', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main( + [ + 'worktree', + 'set', + '--worktree', + 'id:repo::/tmp/repo/child', + '--linear-issue', + 'not-a-linear-link', + '--json' + ], + '/tmp/repo' + ) + + expect(callMock).not.toHaveBeenCalled() + expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain( + 'Pass a Linear issue identifier like STA-335' + ) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + it('passes workspace status through worktree.set', async () => { queueFixtures( callMock, @@ -664,6 +790,191 @@ describe('orca cli worktree awareness', () => { }) }) + it('passes Linear issue metadata through worktree.create', async () => { + queueFixtures( + callMock, + worktreeListFixture([buildWorktree('/tmp/repo', 'main', 'abc', 'repo-1')]), + okFixture('req_create_linear', { + worktree: { + ...buildWorktree('/tmp/repo/feature', 'feature', 'abc', 'repo-1'), + linkedLinearIssue: 'STA-335', + linkedLinearIssueWorkspaceId: null, + linkedLinearIssueOrganizationUrlKey: 'stably' + } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + [ + 'worktree', + 'create', + '--repo', + 'id:repo-1', + '--name', + 'feature', + '--linear-issue', + 'https://linear.app/stably/issue/STA-335/test-issue', + '--json' + ], + '/tmp/repo' + ) + + expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', { + repo: 'id:repo-1', + name: 'feature', + baseBranch: undefined, + linkedIssue: undefined, + linkedLinearIssue: 'STA-335', + linkedLinearIssueWorkspaceId: null, + linkedLinearIssueOrganizationUrlKey: 'stably', + comment: undefined, + runHooks: false, + activate: false, + parentWorktree: undefined, + cwdParentWorktree: 'id:repo-1::/tmp/repo', + noParent: false, + callerTerminalHandle: undefined + }) + }) + + it('normalizes bare Linear identifiers through worktree.create', async () => { + queueFixtures( + callMock, + okFixture('req_create_linear_id', { + worktree: { + ...buildWorktree('/tmp/repo/feature', 'feature', 'abc', 'repo-1'), + linkedLinearIssue: 'STA-335' + }, + lineage: null, + warnings: [] + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main( + [ + 'worktree', + 'create', + '--repo', + 'id:repo-1', + '--name', + 'feature', + '--linear-issue', + 'sta-335', + '--no-parent', + '--json' + ], + '/tmp/repo' + ) + + expect(callMock).toHaveBeenCalledWith('worktree.create', { + repo: 'id:repo-1', + name: 'feature', + baseBranch: undefined, + linkedIssue: undefined, + linkedLinearIssue: 'STA-335', + linkedLinearIssueWorkspaceId: null, + linkedLinearIssueOrganizationUrlKey: null, + comment: undefined, + runHooks: false, + activate: false, + parentWorktree: undefined, + noParent: true, + callerTerminalHandle: undefined + }) + }) + + it('rejects null Linear issue values on worktree.create before RPC', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main( + [ + 'worktree', + 'create', + '--repo', + 'id:repo-1', + '--name', + 'feature', + '--linear-issue', + 'null', + '--no-parent', + '--json' + ], + '/tmp/repo' + ) + + expect(callMock).not.toHaveBeenCalled() + expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain( + 'Omit --linear-issue on create' + ) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + + it('rejects invalid Linear issue values on worktree.create before RPC', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main( + [ + 'worktree', + 'create', + '--repo', + 'id:repo-1', + '--name', + 'feature', + '--linear-issue', + 'not-a-linear-link', + '--no-parent', + '--json' + ], + '/tmp/repo' + ) + + expect(callMock).not.toHaveBeenCalled() + expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain( + 'Pass a Linear issue identifier like STA-335' + ) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + + it('rejects missing Linear issue values on worktree.create before RPC', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const priorExitCode = process.exitCode + + await main( + [ + 'worktree', + 'create', + '--repo', + 'id:repo-1', + '--name', + 'feature', + '--linear-issue', + '--no-parent', + '--json' + ], + '/tmp/repo' + ) + + expect(callMock).not.toHaveBeenCalled() + expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain( + 'Missing value for --linear-issue' + ) + expect(process.exitCode).toBe(1) + + process.exitCode = priorExitCode + }) + it('passes explicit activation through worktree.create', async () => { queueFixtures( callMock, diff --git a/src/cli/specs/core.ts b/src/cli/specs/core.ts index 2a79f75ff..e47c5512f 100644 --- a/src/cli/specs/core.ts +++ b/src/cli/specs/core.ts @@ -102,7 +102,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ path: ['worktree', 'create'], summary: 'Create a new Orca-managed worktree', usage: - 'orca worktree create --name [--repo ] [--agent ] [--prompt ] [--setup run|skip|inherit] [--base-branch ] [--issue ] [--comment ] [--parent-worktree ] [--no-parent] [--run-hooks] [--activate] [--json]', + 'orca worktree create --name [--repo ] [--agent ] [--prompt ] [--setup run|skip|inherit] [--base-branch ] [--issue ] [--linear-issue ] [--comment ] [--parent-worktree ] [--no-parent] [--run-hooks] [--activate] [--json]', allowedFlags: [ ...GLOBAL_FLAGS, 'repo', @@ -111,6 +111,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ 'prompt', 'base-branch', 'issue', + 'linear-issue', 'comment', 'setup', 'parent-worktree', @@ -132,6 +133,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ examples: [ 'orca worktree create --name agent-task --agent codex --prompt "hi" --json', 'orca worktree create --repo id: --name related-task --json', + 'orca worktree create --repo id: --name linear-task --linear-issue https://linear.app/stably/issue/STA-335/test-issue --json', 'orca worktree create --repo id: --name agent-task --agent codex --prompt "hi" --json', 'orca worktree create --repo id: --name related-task --parent-worktree active --json', 'orca worktree create --repo id: --name independent-task --no-parent --json' @@ -141,19 +143,25 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [ path: ['worktree', 'set'], summary: 'Update Orca metadata for a worktree', usage: - 'orca worktree set --worktree [--display-name ] [--issue ] [--comment ] [--workspace-status ] [--parent-worktree |--no-parent] [--json]', + 'orca worktree set --worktree [--display-name ] [--issue ] [--linear-issue ] [--comment ] [--workspace-status ] [--parent-worktree |--no-parent] [--json]', allowedFlags: [ ...GLOBAL_FLAGS, 'worktree', 'display-name', 'issue', + 'linear-issue', 'comment', 'workspace-status', 'parent-worktree', 'no-parent' ], notes: [ - 'Workspace status ids match the board columns (defaults: todo, in-progress, in-review, completed); custom statuses use their configured id.' + 'Workspace status ids match the board columns (defaults: todo, in-progress, in-review, completed); custom statuses use their configured id.', + 'Pass --linear-issue null to clear the Linear issue link.' + ], + examples: [ + 'orca worktree set --worktree active --linear-issue STA-335 --json', + 'orca worktree set --worktree active --linear-issue null --json' ] }, { diff --git a/src/main/linear/issue-context-current.test.ts b/src/main/linear/issue-context-current.test.ts new file mode 100644 index 000000000..fd3804bbe --- /dev/null +++ b/src/main/linear/issue-context-current.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { LinearWorkspace } from '../../shared/types' + +const { connectedWorkspaces } = vi.hoisted(() => ({ + connectedWorkspaces: [] as LinearWorkspace[] +})) + +vi.mock('./issue-context-client', () => ({ + getConnectedWorkspaces: () => connectedWorkspaces +})) + +import { + getLinearCurrentIssueFromWorktree, + resolveLegacyLinearLinkWorkspace +} from './issue-context-current' + +describe('linear issue current worktree link resolution', () => { + beforeEach(() => { + connectedWorkspaces.length = 0 + }) + + it('uses split organization URL key metadata from CLI-created Linear links', () => { + const link = getLinearCurrentIssueFromWorktree({ + id: 'repo::/tmp/worktree', + path: '/tmp/worktree', + linkedLinearIssue: 'sta-335', + linkedLinearIssueWorkspaceId: null, + linkedLinearIssueOrganizationUrlKey: 'stably' + }) + + expect(link).toMatchObject({ + identifier: 'STA-335', + workspaceId: null, + organizationUrlKey: 'stably', + worktreeId: 'repo::/tmp/worktree' + }) + }) + + it('backfills workspace id from split organization URL key metadata', () => { + connectedWorkspaces.push( + makeWorkspace('workspace-1', 'stably'), + makeWorkspace('workspace-2', 'acme') + ) + + expect(resolveLegacyLinearLinkWorkspace('STA-335', 'stably')).toEqual({ + workspaceId: 'workspace-1', + organizationUrlKey: 'stably' + }) + }) + + it('keeps ambiguous split organization URL key backfill workspace-free', () => { + connectedWorkspaces.push( + makeWorkspace('workspace-1', 'stably'), + makeWorkspace('workspace-2', 'stably') + ) + + expect(resolveLegacyLinearLinkWorkspace('STA-335', 'stably')).toEqual({ + organizationUrlKey: 'stably' + }) + }) +}) + +function makeWorkspace(id: string, organizationUrlKey: string): LinearWorkspace { + return { + id, + organizationId: id, + organizationName: organizationUrlKey, + organizationUrlKey, + displayName: organizationUrlKey, + email: `${id}@example.com` + } +} diff --git a/src/main/linear/issue-context-current.ts b/src/main/linear/issue-context-current.ts index 31098920b..dbde6c817 100644 --- a/src/main/linear/issue-context-current.ts +++ b/src/main/linear/issue-context-current.ts @@ -38,9 +38,12 @@ export function getLinearCurrentIssueFromWorktree(worktree: { } } -export function resolveLegacyLinearLinkWorkspace(identifier: string): CurrentIssueLink['backfill'] { +export function resolveLegacyLinearLinkWorkspace( + identifier: string, + splitOrganizationUrlKey?: string | null +): CurrentIssueLink['backfill'] { const parsed = parseLinearIssueInput(identifier) - const organizationUrlKey = parsed?.organizationUrlKey + const organizationUrlKey = splitOrganizationUrlKey ?? parsed?.organizationUrlKey if (!organizationUrlKey) { return undefined } diff --git a/src/main/linear/issue-context.test.ts b/src/main/linear/issue-context.test.ts new file mode 100644 index 000000000..77fc39db7 --- /dev/null +++ b/src/main/linear/issue-context.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { LinearClientForWorkspace } from './client' +import type { LinearWorkspace } from '../../shared/types' + +const getClients = vi.fn() +const getStatus = vi.fn() +const isAuthError = vi.fn() +const clearToken = vi.fn() + +vi.mock('./client', () => ({ + acquire: vi.fn().mockResolvedValue(undefined), + release: vi.fn(), + getClients: (...args: unknown[]) => getClients(...args), + getStatus: (...args: unknown[]) => getStatus(...args), + isAuthError: (...args: unknown[]) => isAuthError(...args), + clearToken: (...args: unknown[]) => clearToken(...args) +})) + +function workspace(id: string, organizationUrlKey: string): LinearWorkspace { + return { + id, + organizationId: id, + organizationName: organizationUrlKey, + organizationUrlKey, + displayName: 'Ada', + email: 'ada@example.com' + } +} + +function makeEntry(options: { + workspace: LinearWorkspace + rawRequest: ReturnType +}): LinearClientForWorkspace { + return { + workspace: options.workspace, + client: { + client: { rawRequest: options.rawRequest } + } + } as unknown as LinearClientForWorkspace +} + +function rawIssue(identifier: string) { + return { + id: `${identifier}-id`, + identifier, + title: `Title ${identifier}`, + url: `https://linear.app/stably/issue/${identifier}`, + labels: { nodes: [] } + } +} + +describe('Linear issue context', () => { + beforeEach(() => { + vi.clearAllMocks() + getStatus.mockReturnValue({ workspaces: [] }) + isAuthError.mockReturnValue(false) + }) + + it('resolves --current worktree links written as split Linear CLI metadata', async () => { + const stably = workspace('workspace-stably', 'stably') + const rawRequest = vi.fn().mockResolvedValue({ data: { issue: rawIssue('STA-335') } }) + getStatus.mockReturnValue({ workspaces: [stably] }) + getClients.mockReturnValue([makeEntry({ workspace: stably, rawRequest })]) + const { readLinearIssueContext } = await import('./issue-context') + + await expect( + readLinearIssueContext( + { + current: true, + include: { attachments: false, children: false, comments: false, relations: false }, + depth: 0 + }, + async () => ({ + identifier: 'STA-335', + workspaceId: null, + organizationUrlKey: 'stably', + worktreeId: 'repo::/tmp/repo/feature', + worktreePath: '/tmp/repo/feature' + }) + ) + ).resolves.toMatchObject({ + issue: { identifier: 'STA-335' }, + meta: { + resolved: { + workspaceId: 'workspace-stably', + worktreeId: 'repo::/tmp/repo/feature', + worktreePath: '/tmp/repo/feature' + } + } + }) + expect(getClients).toHaveBeenCalledWith('workspace-stably') + }) +}) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 263476b19..fe0722d6f 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -15083,7 +15083,10 @@ export class OrcaRuntimeService { const link = getLinearCurrentIssueFromWorktree(worktree) if (!link.workspaceId) { - const backfill = resolveLegacyLinearLinkWorkspace(worktree.linkedLinearIssue ?? '') + const backfill = resolveLegacyLinearLinkWorkspace( + worktree.linkedLinearIssue ?? '', + worktree.linkedLinearIssueOrganizationUrlKey + ) if (backfill?.workspaceId) { this.store.setWorktreeMeta(worktree.id, { linkedLinearIssueWorkspaceId: backfill.workspaceId, diff --git a/src/main/runtime/rpc/methods/worktree-schemas.ts b/src/main/runtime/rpc/methods/worktree-schemas.ts index a4c49ca41..43cbe975b 100644 --- a/src/main/runtime/rpc/methods/worktree-schemas.ts +++ b/src/main/runtime/rpc/methods/worktree-schemas.ts @@ -155,6 +155,8 @@ export const WorktreeSet = WorktreeSelector.extend({ linkedIssue: TriStateLinkedIssue, linkedPR: TriStateLinkedIssue, linkedLinearIssue: z.union([z.string(), z.null()]).optional(), + linkedLinearIssueWorkspaceId: z.union([z.string(), z.null()]).optional(), + linkedLinearIssueOrganizationUrlKey: z.union([z.string(), z.null()]).optional(), linkedGitLabMR: TriStateLinkedIssue, linkedGitLabIssue: TriStateLinkedIssue, isArchived: OptionalBoolean, diff --git a/src/main/runtime/rpc/methods/worktree.test.ts b/src/main/runtime/rpc/methods/worktree.test.ts index 4fd6af9a6..e82aea583 100644 --- a/src/main/runtime/rpc/methods/worktree.test.ts +++ b/src/main/runtime/rpc/methods/worktree.test.ts @@ -45,6 +45,8 @@ describe('worktree RPC methods', () => { linkedIssue: 123, linkedPR: 456, linkedLinearIssue: undefined, + linkedLinearIssueWorkspaceId: undefined, + linkedLinearIssueOrganizationUrlKey: undefined, linkedGitLabIssue: 789, linkedGitLabMR: 321, comment: undefined, @@ -258,6 +260,33 @@ describe('worktree RPC methods', () => { }) }) + it('forwards Linear metadata through worktree.set', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + updateManagedWorktreeMeta: vi.fn().mockResolvedValue({ id: 'wt-1' }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('worktree.set', { + worktree: 'id:wt-1', + linkedLinearIssue: 'STA-335', + linkedLinearIssueWorkspaceId: null, + linkedLinearIssueOrganizationUrlKey: 'stably' + }) + ) + + expect(response).toMatchObject({ ok: true }) + expect(runtime.updateManagedWorktreeMeta).toHaveBeenCalledWith( + 'id:wt-1', + expect.objectContaining({ + linkedLinearIssue: 'STA-335', + linkedLinearIssueWorkspaceId: null, + linkedLinearIssueOrganizationUrlKey: 'stably' + }) + ) + }) + it('rejects worktree.set when both parent and no-parent are supplied', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index 27abe8e4c..e715ba0a2 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -117,6 +117,8 @@ export const WORKTREE_METHODS: RpcMethod[] = [ linkedIssue: params.linkedIssue, linkedPR: params.linkedPR, linkedLinearIssue: params.linkedLinearIssue, + linkedLinearIssueWorkspaceId: params.linkedLinearIssueWorkspaceId, + linkedLinearIssueOrganizationUrlKey: params.linkedLinearIssueOrganizationUrlKey, linkedGitLabMR: params.linkedGitLabMR, linkedGitLabIssue: params.linkedGitLabIssue, comment: params.comment, diff --git a/src/main/runtime/rpc/schemas.test.ts b/src/main/runtime/rpc/schemas.test.ts index 4d9cc3609..9712f1c23 100644 --- a/src/main/runtime/rpc/schemas.test.ts +++ b/src/main/runtime/rpc/schemas.test.ts @@ -76,6 +76,12 @@ describe('RPC optional pipe schemas', () => { telemetrySource: 'raw-source' }) expectParses(methodParams(WORKTREE_METHODS, 'worktree.create'), { repo: 'repo-1' }) + expectParses(methodParams(WORKTREE_METHODS, 'worktree.set'), { + worktree: 'id:wt-1', + linkedLinearIssue: 'STA-335', + linkedLinearIssueWorkspaceId: null, + linkedLinearIssueOrganizationUrlKey: 'stably' + }) expectParses(methodParams(WORKTREE_METHODS, 'worktree.prefetchCreateBase'), { repo: 'repo-1' }) }) }) diff --git a/src/shared/linear-links.test.ts b/src/shared/linear-links.test.ts index ea65a2b7a..de7993714 100644 --- a/src/shared/linear-links.test.ts +++ b/src/shared/linear-links.test.ts @@ -52,6 +52,10 @@ describe('linear links', () => { identifier: 'ENG-123', organizationUrlKey: 'acme' }) + expect(parseLinearIssueInput('https://linear.app/stably/issue/STA-335/test-issue')).toEqual({ + identifier: 'STA-335', + organizationUrlKey: 'stably' + }) }) it('rejects non-Linear issue input', () => {