Files
orca/src/main/ssh/ssh-remote-linear-write-cli.ts
Brennan Benson a10a2ba53c feat(linear): add MCP-style save issue (#9670)
* feat(linear): add MCP-style save issue

* fix(linear): harden save issue parity

* fix(linear): close save issue contract gaps

* docs(linear): bundle project discovery with save issue
2026-07-21 13:25:22 -07:00

258 lines
9.0 KiB
TypeScript

import type { RpcResponse } from '../runtime/rpc/core'
import type { RpcDispatcher } from '../runtime/rpc/dispatcher'
import { getRemoteLinearWriteHelp } from './ssh-remote-linear-write-help'
import { dispatchRemoteLinearRelationWrite } from './ssh-remote-linear-relation-write'
import { dispatchRemoteLinearSaveIssue } from './ssh-remote-linear-save-issue'
import {
RemoteLinearWriteArgumentError,
buildRemoteContext,
buildRemoteTargetRequest,
call,
dueDateFlag,
isRemoteCommand,
nonNegativeIntegerFlag,
optionalString,
optionalWriteId,
priorityFlag,
readRemoteBody,
repeatedString,
rejectAllWorkspaceForWrite,
requiredHttpUrl,
requiredString,
validateLinearRemoteArgs
} from './ssh-remote-linear-write-support'
type ParsedRemoteCli = {
commandPath: string[]
flags: Map<string, string | boolean>
}
export { getRemoteLinearWriteHelp }
const LINEAR_WRITE_FLAGS = new Set(['help', 'json', 'pairing-code', 'environment', 'workspace'])
const LINEAR_TARGET_WRITE_FLAGS = new Set([...LINEAR_WRITE_FLAGS, 'current', 'id'])
const LINEAR_STATUS_FLAGS = new Set([...LINEAR_TARGET_WRITE_FLAGS, 'to'])
const LINEAR_ASSIGNEE_SET_FLAGS = new Set([...LINEAR_TARGET_WRITE_FLAGS, 'me', 'to-id'])
const LINEAR_TASK_SET_FLAGS = new Set([...LINEAR_TARGET_WRITE_FLAGS, 'to'])
const LINEAR_TASK_CLEAR_FLAGS = LINEAR_TARGET_WRITE_FLAGS
const LINEAR_LABEL_FLAGS = new Set([...LINEAR_TARGET_WRITE_FLAGS, 'label'])
const LINEAR_COMMENT_FLAGS = new Set([
...LINEAR_TARGET_WRITE_FLAGS,
'body',
'body-file',
'reply-to',
'write-id'
])
const LINEAR_ATTACH_FLAGS = new Set([...LINEAR_TARGET_WRITE_FLAGS, 'url', 'title', 'write-id'])
const LINEAR_CREATE_FLAGS = new Set([
...LINEAR_WRITE_FLAGS,
'title',
'body',
'body-file',
'team',
'project',
'state',
'assignee',
'priority',
'estimate',
'due-date',
'label',
'parent',
'parent-current',
'write-id'
])
export async function tryDispatchRemoteLinearWriteCli(
dispatcher: RpcDispatcher,
parsed: ParsedRemoteCli,
env: Record<string, string>,
stdin?: string
): Promise<RpcResponse | null> {
if (isRemoteCommand(parsed, 'linear', 'save-issue')) {
return await dispatchRemoteLinearSaveIssue(dispatcher, parsed, env, stdin)
}
if (isRemoteCommand(parsed, 'linear', 'relation', 'add')) {
return await dispatchRemoteLinearRelationWrite(dispatcher, parsed, env, 'add')
}
if (
isRemoteCommand(parsed, 'linear', 'relation', 'remove') ||
isRemoteCommand(parsed, 'linear', 'relation', 'rm')
) {
return await dispatchRemoteLinearRelationWrite(dispatcher, parsed, env, 'remove')
}
if (isRemoteCommand(parsed, 'linear', 'status', 'set')) {
validateLinearRemoteArgs(parsed, LINEAR_STATUS_FLAGS, ['linear', 'status', 'set'], 1, 'id')
return await call(dispatcher, 'linear.issueSetState', {
...buildRemoteTargetRequest(parsed, env, 3),
to: requiredString(parsed.flags, 'to')
})
}
if (isRemoteCommand(parsed, 'linear', 'assignee', 'set')) {
validateLinearRemoteArgs(
parsed,
LINEAR_ASSIGNEE_SET_FLAGS,
['linear', 'assignee', 'set'],
1,
'id'
)
const me = parsed.flags.get('me') === true
const toId = optionalString(parsed.flags, 'to-id')
if (me === Boolean(toId)) {
throw new RemoteLinearWriteArgumentError(
'invalid_argument',
'Pass exactly one of --me or --to-id'
)
}
return await call(dispatcher, 'linear.issueUpdateTask', {
...buildRemoteTargetRequest(parsed, env, 3),
operation: 'assignee',
...(me ? { assigneeMe: true } : { assigneeId: toId })
})
}
if (isRemoteCommand(parsed, 'linear', 'assignee', 'clear')) {
validateLinearRemoteArgs(
parsed,
LINEAR_TASK_CLEAR_FLAGS,
['linear', 'assignee', 'clear'],
1,
'id'
)
return await call(dispatcher, 'linear.issueUpdateTask', {
...buildRemoteTargetRequest(parsed, env, 3),
operation: 'assignee',
assigneeId: null
})
}
if (isRemoteCommand(parsed, 'linear', 'priority', 'set')) {
validateLinearRemoteArgs(parsed, LINEAR_TASK_SET_FLAGS, ['linear', 'priority', 'set'], 1, 'id')
return await call(dispatcher, 'linear.issueUpdateTask', {
...buildRemoteTargetRequest(parsed, env, 3),
operation: 'priority',
priority: priorityFlag(parsed.flags, 'to')
})
}
if (isRemoteCommand(parsed, 'linear', 'priority', 'clear')) {
validateLinearRemoteArgs(
parsed,
LINEAR_TASK_CLEAR_FLAGS,
['linear', 'priority', 'clear'],
1,
'id'
)
return await call(dispatcher, 'linear.issueUpdateTask', {
...buildRemoteTargetRequest(parsed, env, 3),
operation: 'priority',
priority: 0
})
}
if (isRemoteCommand(parsed, 'linear', 'estimate', 'set')) {
validateLinearRemoteArgs(parsed, LINEAR_TASK_SET_FLAGS, ['linear', 'estimate', 'set'], 1, 'id')
return await call(dispatcher, 'linear.issueUpdateTask', {
...buildRemoteTargetRequest(parsed, env, 3),
operation: 'estimate',
estimate: nonNegativeIntegerFlag(parsed.flags, 'to')
})
}
if (isRemoteCommand(parsed, 'linear', 'estimate', 'clear')) {
validateLinearRemoteArgs(
parsed,
LINEAR_TASK_CLEAR_FLAGS,
['linear', 'estimate', 'clear'],
1,
'id'
)
return await call(dispatcher, 'linear.issueUpdateTask', {
...buildRemoteTargetRequest(parsed, env, 3),
operation: 'estimate',
estimate: null
})
}
if (isRemoteCommand(parsed, 'linear', 'due-date', 'set')) {
validateLinearRemoteArgs(parsed, LINEAR_TASK_SET_FLAGS, ['linear', 'due-date', 'set'], 1, 'id')
return await call(dispatcher, 'linear.issueUpdateTask', {
...buildRemoteTargetRequest(parsed, env, 3),
operation: 'dueDate',
dueDate: dueDateFlag(parsed.flags, 'to')
})
}
if (isRemoteCommand(parsed, 'linear', 'due-date', 'clear')) {
validateLinearRemoteArgs(
parsed,
LINEAR_TASK_CLEAR_FLAGS,
['linear', 'due-date', 'clear'],
1,
'id'
)
return await call(dispatcher, 'linear.issueUpdateTask', {
...buildRemoteTargetRequest(parsed, env, 3),
operation: 'dueDate',
dueDate: null
})
}
for (const mode of ['add', 'remove', 'set'] as const) {
if (isRemoteCommand(parsed, 'linear', 'label', mode)) {
validateLinearRemoteArgs(parsed, LINEAR_LABEL_FLAGS, ['linear', 'label', mode], 1, 'id')
const labels = repeatedString(parsed.flags, 'label')
if (labels.length === 0) {
throw new RemoteLinearWriteArgumentError('invalid_argument', 'Missing required --label')
}
return await call(dispatcher, 'linear.issueUpdateTask', {
...buildRemoteTargetRequest(parsed, env, 3),
operation: 'labels',
labelMode: mode,
labels
})
}
}
if (isRemoteCommand(parsed, 'linear', 'comment', 'add')) {
validateLinearRemoteArgs(parsed, LINEAR_COMMENT_FLAGS, ['linear', 'comment', 'add'], 1, 'id')
return await call(dispatcher, 'linear.issueAddComment', {
...buildRemoteTargetRequest(parsed, env, 3),
body: readRemoteBody(parsed.flags, true, stdin),
replyTo: optionalString(parsed.flags, 'reply-to'),
writeId: optionalWriteId(parsed.flags)
})
}
if (isRemoteCommand(parsed, 'linear', 'attach')) {
validateLinearRemoteArgs(parsed, LINEAR_ATTACH_FLAGS, ['linear', 'attach'], 1, 'id')
return await call(dispatcher, 'linear.issueAttachLink', {
...buildRemoteTargetRequest(parsed, env, 2),
url: requiredHttpUrl(parsed.flags, 'url'),
title: optionalString(parsed.flags, 'title'),
writeId: optionalWriteId(parsed.flags)
})
}
if (isRemoteCommand(parsed, 'linear', 'create')) {
validateLinearRemoteArgs(parsed, LINEAR_CREATE_FLAGS, ['linear', 'create'], 0, 'id')
rejectAllWorkspaceForWrite(parsed.flags)
const parentInput = optionalString(parsed.flags, 'parent')
const parentCurrent = parsed.flags.get('parent-current') === true
if (parentInput && parentCurrent) {
throw new RemoteLinearWriteArgumentError(
'invalid_argument',
'Use either --parent or --parent-current, not both'
)
}
const body = readRemoteBody(parsed.flags, false, stdin)
return await call(dispatcher, 'linear.issueCreate', {
title: requiredString(parsed.flags, 'title'),
...(body !== undefined ? { body } : {}),
teamInput: optionalString(parsed.flags, 'team'),
projectInput: optionalString(parsed.flags, 'project'),
state: optionalString(parsed.flags, 'state'),
assignee: optionalString(parsed.flags, 'assignee'),
priority: parsed.flags.has('priority') ? priorityFlag(parsed.flags, 'priority') : undefined,
estimate: parsed.flags.has('estimate')
? nonNegativeIntegerFlag(parsed.flags, 'estimate')
: undefined,
dueDate: parsed.flags.has('due-date') ? dueDateFlag(parsed.flags, 'due-date') : undefined,
labels: repeatedString(parsed.flags, 'label'),
parentInput,
parentCurrent,
workspaceId: optionalString(parsed.flags, 'workspace'),
writeId: optionalWriteId(parsed.flags),
context: buildRemoteContext(env)
})
}
return null
}