Attach Linear issues from the worktree CLI (#5322)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
eec18be688
commit
e7906969cf
|
|
@ -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<string, string | boolean>,
|
||||
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<string, string | boolean>,
|
||||
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}`)
|
||||
}
|
||||
|
|
@ -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<string, CommandHandler> = {
|
|||
cwdParentWorktree = undefined
|
||||
}
|
||||
}
|
||||
const linearIssueLink = getOptionalLinearIssueLinkFlag(flags, 'linear-issue')
|
||||
const result = await client.call<RuntimeWorktreeCreateResult>('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<string, CommandHandler> = {
|
|||
},
|
||||
'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),
|
||||
|
|
|
|||
|
|
@ -185,10 +185,10 @@ Common Commands:
|
|||
orca environment show --environment <selector> [--json]
|
||||
orca environment rm --environment <selector> [--json]
|
||||
orca worktree list [--repo <selector>] [--limit <n>] [--json]
|
||||
orca worktree create --name <name> [--repo <selector>] [--agent <id>] [--prompt <text>] [--setup run|skip|inherit] [--base-branch <ref>] [--issue <number>] [--comment <text>] [--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]
|
||||
orca worktree create --name <name> [--repo <selector>] [--agent <id>] [--prompt <text>] [--setup run|skip|inherit] [--base-branch <ref>] [--issue <number>] [--linear-issue <identifier-or-url>] [--comment <text>] [--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]
|
||||
orca worktree show --worktree <selector> [--json]
|
||||
orca worktree current [--json]
|
||||
orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--comment <text>] [--workspace-status <id>] [--parent-worktree <selector>|--no-parent] [--json]
|
||||
orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--linear-issue <identifier-or-url|null>] [--comment <text>] [--workspace-status <id>] [--parent-worktree <selector>|--no-parent] [--json]
|
||||
orca worktree rm --worktree <selector> [--force] [--run-hooks] [--json]
|
||||
orca worktree ps [--limit <n>] [--json]
|
||||
orca file open <path> [--worktree <selector>] [--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 <id> Identifier for a target item or permission',
|
||||
issue: '--issue <number|null> Linked GitHub issue number',
|
||||
'linear-issue':
|
||||
'--linear-issue <id|url|null> Linked Linear issue identifier or URL; null clears on set',
|
||||
json: '--json Emit machine-readable JSON',
|
||||
key: '--key <key> Key argument for this command',
|
||||
limit: '--limit <n> Maximum number of rows to return',
|
||||
|
|
|
|||
|
|
@ -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 <identifier-or-url>')
|
||||
|
||||
logSpy.mockClear()
|
||||
await main(['worktree', 'set', '--help'], '/tmp/repo')
|
||||
|
||||
const setHelp = String(logSpy.mock.calls[0][0])
|
||||
expect(setHelp).toContain('--linear-issue <identifier-or-url|null>')
|
||||
expect(setHelp).toContain('--linear-issue <id|url|null> 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,
|
||||
|
|
|
|||
|
|
@ -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 <name> [--repo <selector>] [--agent <id>] [--prompt <text>] [--setup run|skip|inherit] [--base-branch <ref>] [--issue <number>] [--comment <text>] [--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]',
|
||||
'orca worktree create --name <name> [--repo <selector>] [--agent <id>] [--prompt <text>] [--setup run|skip|inherit] [--base-branch <ref>] [--issue <number>] [--linear-issue <identifier-or-url>] [--comment <text>] [--parent-worktree <selector>] [--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:<repoId> --name related-task --json',
|
||||
'orca worktree create --repo id:<repoId> --name linear-task --linear-issue https://linear.app/stably/issue/STA-335/test-issue --json',
|
||||
'orca worktree create --repo id:<repoId> --name agent-task --agent codex --prompt "hi" --json',
|
||||
'orca worktree create --repo id:<repoId> --name related-task --parent-worktree active --json',
|
||||
'orca worktree create --repo id:<repoId> --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 <selector> [--display-name <name>] [--issue <number|null>] [--comment <text>] [--workspace-status <id>] [--parent-worktree <selector>|--no-parent] [--json]',
|
||||
'orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--linear-issue <identifier-or-url|null>] [--comment <text>] [--workspace-status <id>] [--parent-worktree <selector>|--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'
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<typeof vi.fn>
|
||||
}): 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue