Merge remote-tracking branch 'origin/main' into Jinwoo-H/feature-control-multiple-orca-servers-concurrent

This commit is contained in:
Jinwoo-H
2026-06-12 17:17:27 -07:00
495 changed files with 35128 additions and 11674 deletions
+1
View File
@@ -36,6 +36,7 @@ export const BOOLEAN_FLAGS = new Set([
'mobile',
'mobile-pairing',
'no-pairing',
'parent-current',
'ready',
'relations',
'restore-window',
+233
View File
@@ -182,6 +182,169 @@ describe('orca linear CLI handlers', () => {
workspaceId: undefined
})
})
it('maps status writes to the agent write RPC with an explicit target', async () => {
queueFixtures(callMock, okFixture('req_status', statusSetResult()))
await main(['linear', 'status', 'set', 'ENG-123', '--to', 'In Review', '--json'], '/tmp/repo')
expect(callMock).toHaveBeenCalledWith(
'linear.issueSetState',
{
input: 'ENG-123',
current: false,
workspaceId: undefined,
to: 'In Review',
context: {
remote: false,
cwd: '/tmp/repo'
}
},
{ timeoutMs: 75_000 }
)
})
it('requires exact write targets for issue writes', async () => {
await main(['linear', 'comment', 'add', '--body', 'done'], '/tmp/repo')
expect(callMock).not.toHaveBeenCalled()
expect(vi.mocked(console.error).mock.calls[0][0]).toContain(
'Pass a Linear issue id or --current'
)
expect(process.exitCode).toBe(1)
})
it('rejects --workspace all for writes before dispatch', async () => {
await main(
[
'linear',
'attach',
'ENG-123',
'--url',
'https://example.com/review/123',
'--workspace',
'all'
],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect(vi.mocked(console.error).mock.calls[0][0]).toContain(
'--workspace all is not valid for Linear writes'
)
expect(process.exitCode).toBe(1)
})
it('reads comment bodies from stdin and passes retry write ids through', async () => {
const stdin = mockStdin(false, ['line one\n', 'line two'])
queueFixtures(callMock, okFixture('req_comment', commentAddResult()))
try {
await main(
[
'linear',
'comment',
'add',
'--current',
'--body-file',
'-',
'--reply-to',
'comment-parent',
'--write-id',
'123e4567-e89b-12d3-a456-426614174000',
'--json'
],
'/tmp/repo'
)
} finally {
stdin.restore()
}
expect(callMock).toHaveBeenCalledWith(
'linear.issueAddComment',
{
input: undefined,
current: true,
workspaceId: undefined,
body: 'line one\nline two',
replyTo: 'comment-parent',
writeId: '123e4567-e89b-12d3-a456-426614174000',
context: {
remote: false,
cwd: '/tmp/repo'
}
},
{ timeoutMs: 75_000 }
)
})
it('rejects malformed write ids before dispatch', async () => {
await main(
[
'linear',
'attach',
'ENG-123',
'--url',
'https://example.com/review/123',
'--write-id',
'not-a-uuid',
'--json'
],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
const payload = JSON.parse(String(vi.mocked(console.log).mock.calls[0][0])) as {
error: { code: string }
}
expect(payload.error.code).toBe('linear_invalid_write_id')
})
it('maps create with parent-current and optional body flags', async () => {
queueFixtures(callMock, okFixture('req_create', createResult()))
await main(
[
'linear',
'create',
'--title',
'Follow-up bug',
'--body',
'Concrete repro',
'--parent-current',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith(
'linear.issueCreate',
{
title: 'Follow-up bug',
body: 'Concrete repro',
teamKey: undefined,
parentInput: undefined,
parentCurrent: true,
workspaceId: undefined,
writeId: undefined,
context: {
remote: false,
cwd: '/tmp/repo'
}
},
{ timeoutMs: 75_000 }
)
})
it('rejects duplicate body inputs before dispatch', async () => {
await main(
['linear', 'create', '--title', 'Bug', '--body', 'one', '--body-file', 'body.md'],
'/tmp/repo'
)
expect(callMock).not.toHaveBeenCalled()
expect(vi.mocked(console.error).mock.calls[0][0]).toContain('Use either --body or --body-file')
})
})
function issueResult(): unknown {
@@ -213,3 +376,73 @@ function issueResult(): unknown {
}
}
}
function statusSetResult(): unknown {
return {
issue: { id: 'issue-id', identifier: 'ENG-123', url: 'https://linear.app/acme/issue/ENG-123' },
state: { id: 'state-review', name: 'In Review', type: 'started' },
previousState: { id: 'state-started', name: 'In Progress' },
meta: { workspaceId: 'workspace-1', alreadyInState: false }
}
}
function commentAddResult(): unknown {
return {
comment: { id: 'comment-id', url: null, parentId: 'comment-parent' },
issue: { id: 'issue-id', identifier: 'ENG-123', url: 'https://linear.app/acme/issue/ENG-123' },
meta: {
workspaceId: 'workspace-1',
bodyChars: 17,
writeId: '123e4567-e89b-12d3-a456-426614174000',
deduplicated: false
}
}
}
function createResult(): unknown {
return {
issue: {
id: 'issue-child',
identifier: 'ENG-456',
title: 'Follow-up bug',
url: 'https://linear.app/acme/issue/ENG-456',
team: { id: 'team-eng', key: 'ENG', name: 'Engineering' },
state: { id: 'state-triage', name: 'Triage' },
parent: { id: 'issue-id', identifier: 'ENG-123' }
},
meta: {
workspaceId: 'workspace-1',
writeId: '123e4567-e89b-12d3-a456-426614174000',
deduplicated: false
}
}
}
function mockStdin(isTTY: boolean, chunks: string[]): { restore: () => void } {
const stdin = process.stdin
const previousIsTTY = stdin.isTTY
const previousAsyncIterator = stdin[Symbol.asyncIterator]
Object.defineProperty(stdin, 'isTTY', {
configurable: true,
value: isTTY
})
;(stdin as unknown as Record<symbol, unknown>)[Symbol.asyncIterator] = async function* () {
for (const chunk of chunks) {
yield chunk
}
return undefined
}
return {
restore: () => {
Object.defineProperty(stdin, 'isTTY', {
configurable: true,
value: previousIsTTY
})
if (previousAsyncIterator) {
;(stdin as unknown as Record<symbol, unknown>)[Symbol.asyncIterator] = previousAsyncIterator
} else {
Reflect.deleteProperty(stdin, Symbol.asyncIterator)
}
}
}
}
+200 -9
View File
@@ -1,11 +1,23 @@
import { readFile } from 'node:fs/promises'
import { isAbsolute, join } from 'node:path'
import type {
LinearAttachRequest,
LinearAttachResult,
LinearCommentAddRequest,
LinearCommentAddResult,
LinearCreateRequest,
LinearCreateResult,
LinearIssueContextResult,
LinearIssueInclude,
LinearIssueRequest,
LinearSearchResult
LinearSearchResult,
LinearStatusSetRequest,
LinearStatusSetResult,
LinearWriteTargetRequest
} from '../../shared/linear-agent-access'
import {
LINEAR_CHILDREN_MAX_DEPTH,
LINEAR_WRITE_BODY_CAP,
clampLinearIssueDepth,
clampLinearSearchLimit
} from '../../shared/linear-agent-access'
@@ -15,17 +27,24 @@ import {
getOptionalNonNegativeIntegerFlag,
getOptionalPositiveIntegerFlag,
getOptionalStringFlag,
getRequiredStringFlagAllowingEmpty,
getRequiredStringFlag
} from '../flags'
import { RuntimeClientError } from '../runtime-client'
import {
formatLinearAttach,
formatLinearCommentAdd,
formatLinearCreate,
formatLinearIssue,
formatLinearSearch,
formatLinearStatusSet,
printLinearIssueWarnings,
printLinearSearchWarnings
} from '../linear-format'
const ISSUE_CONTEXT_TIMEOUT_MS = 120_000
const LINEAR_WRITE_TIMEOUT_MS = 75_000
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
export const LINEAR_HANDLERS: Record<string, CommandHandler> = {
'linear issue': async ({ flags, client, cwd, json }) => {
@@ -49,6 +68,67 @@ export const LINEAR_HANDLERS: Record<string, CommandHandler> = {
printLinearSearchWarnings(response.result)
}
printResult(response, json, formatLinearSearch)
},
'linear status set': async ({ flags, client, cwd, json }) => {
const request: LinearStatusSetRequest = {
...buildWriteTargetRequest(flags, cwd, client.isRemote),
to: getRequiredStringFlag(flags, 'to')
}
const response = await client.call<LinearStatusSetResult>('linear.issueSetState', request, {
timeoutMs: LINEAR_WRITE_TIMEOUT_MS
})
printResult(response, json, formatLinearStatusSet)
},
'linear comment add': async ({ flags, client, cwd, json }) => {
const body = await readLinearBody(flags, cwd, { required: true })
const request: LinearCommentAddRequest = {
...buildWriteTargetRequest(flags, cwd, client.isRemote),
body,
replyTo: getOptionalStringFlag(flags, 'reply-to'),
writeId: getOptionalWriteId(flags)
}
const response = await client.call<LinearCommentAddResult>('linear.issueAddComment', request, {
timeoutMs: LINEAR_WRITE_TIMEOUT_MS
})
printResult(response, json, formatLinearCommentAdd)
},
'linear attach': async ({ flags, client, cwd, json }) => {
const request: LinearAttachRequest = {
...buildWriteTargetRequest(flags, cwd, client.isRemote),
url: getHttpUrlFlag(flags, 'url'),
title: getOptionalStringFlag(flags, 'title'),
writeId: getOptionalWriteId(flags)
}
const response = await client.call<LinearAttachResult>('linear.issueAttachLink', request, {
timeoutMs: LINEAR_WRITE_TIMEOUT_MS
})
printResult(response, json, formatLinearAttach)
},
'linear create': async ({ flags, client, cwd, json }) => {
rejectAllWorkspaceForWrite(flags)
const parentInput = getOptionalStringFlag(flags, 'parent')
const parentCurrent = flags.get('parent-current') === true
if (parentInput && parentCurrent) {
throw new RuntimeClientError(
'invalid_argument',
'Use either --parent or --parent-current, not both'
)
}
const body = await readLinearBody(flags, cwd, { required: false })
const request: LinearCreateRequest = {
title: getRequiredStringFlag(flags, 'title'),
...(body !== undefined ? { body } : {}),
teamKey: getOptionalStringFlag(flags, 'team'),
parentInput,
parentCurrent,
workspaceId: getOptionalStringFlag(flags, 'workspace'),
writeId: getOptionalWriteId(flags),
context: buildLinearCurrentContext(cwd, client.isRemote)
}
const response = await client.call<LinearCreateResult>('linear.issueCreate', request, {
timeoutMs: LINEAR_WRITE_TIMEOUT_MS
})
printResult(response, json, formatLinearCreate)
}
}
@@ -88,13 +168,124 @@ function buildIssueRequest(
workspaceId,
include: includes,
depth: clampLinearIssueDepth(requestedDepth),
context: {
remote,
...(remote ? {} : { cwd }),
...(process.env.ORCA_WORKTREE_ID ? { worktreeId: process.env.ORCA_WORKTREE_ID } : {}),
...(process.env.ORCA_TERMINAL_HANDLE
? { terminalHandle: process.env.ORCA_TERMINAL_HANDLE }
: {})
}
context: buildLinearCurrentContext(cwd, remote)
}
}
function buildWriteTargetRequest(
flags: Map<string, string | boolean>,
cwd: string,
remote: boolean
): LinearWriteTargetRequest {
rejectAllWorkspaceForWrite(flags)
const input = getOptionalStringFlag(flags, 'id')
const current = flags.get('current') === true
if (input && current) {
throw new RuntimeClientError('invalid_argument', 'Pass either <id> or --current, not both')
}
if (!input && !current) {
throw new RuntimeClientError('linear_issue_required', 'Pass a Linear issue id or --current')
}
return {
input,
current,
workspaceId: getOptionalStringFlag(flags, 'workspace'),
context: buildLinearCurrentContext(cwd, remote)
}
}
function buildLinearCurrentContext(cwd: string, remote: boolean): LinearIssueRequest['context'] {
return {
remote,
...(remote ? {} : { cwd }),
...(process.env.ORCA_WORKTREE_ID ? { worktreeId: process.env.ORCA_WORKTREE_ID } : {}),
...(process.env.ORCA_TERMINAL_HANDLE
? { terminalHandle: process.env.ORCA_TERMINAL_HANDLE }
: {})
}
}
function rejectAllWorkspaceForWrite(flags: Map<string, string | boolean>): void {
if (getOptionalStringFlag(flags, 'workspace') === 'all') {
throw new RuntimeClientError(
'linear_invalid_workspace',
'--workspace all is not valid for Linear writes'
)
}
}
function getOptionalWriteId(flags: Map<string, string | boolean>): string | undefined {
if (!flags.has('write-id')) {
return undefined
}
const writeId = getRequiredStringFlag(flags, 'write-id')
if (!UUID_PATTERN.test(writeId)) {
throw new RuntimeClientError('linear_invalid_write_id', '--write-id must be a UUID')
}
return writeId
}
function getHttpUrlFlag(flags: Map<string, string | boolean>, name: string): string {
const value = getRequiredStringFlag(flags, name)
try {
const parsed = new URL(value)
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
return value
}
} catch {
// Fall through to the stable Linear error below.
}
throw new RuntimeClientError('linear_invalid_url', '--url must be an absolute http(s) URL')
}
function readLinearBody(
flags: Map<string, string | boolean>,
cwd: string,
options: { required: true }
): Promise<string>
function readLinearBody(
flags: Map<string, string | boolean>,
cwd: string,
options: { required: false }
): Promise<string | undefined>
async function readLinearBody(
flags: Map<string, string | boolean>,
cwd: string,
options: { required: boolean }
): Promise<string | undefined> {
const hasBody = flags.has('body')
const hasBodyFile = flags.has('body-file')
if (hasBody && hasBodyFile) {
throw new RuntimeClientError('invalid_argument', 'Use either --body or --body-file, not both')
}
if (!hasBody && !hasBodyFile) {
if (options.required) {
throw new RuntimeClientError('invalid_argument', 'Missing --body or --body-file')
}
return undefined
}
const body = hasBody
? getRequiredStringFlagAllowingEmpty(flags, 'body')
: await readLinearBodyFile(getRequiredStringFlag(flags, 'body-file'), cwd)
if (body.length > LINEAR_WRITE_BODY_CAP) {
throw new RuntimeClientError(
'linear_body_too_large',
`Linear body must be at most ${LINEAR_WRITE_BODY_CAP} characters`
)
}
return body
}
async function readLinearBodyFile(path: string, cwd: string): Promise<string> {
if (path !== '-') {
return await readFile(isAbsolute(path) ? path : join(cwd, path), 'utf8')
}
if (process.stdin.isTTY) {
throw new RuntimeClientError('invalid_argument', 'stdin body requested but stdin is a TTY')
}
const chunks: Buffer[] = []
for await (const chunk of process.stdin) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)))
}
return Buffer.concat(chunks).toString('utf8')
}
+36
View File
@@ -387,6 +387,42 @@ function formatCommandFlagHelp(flag: string, commandPath: string[]): string {
if (command === 'linear search' && flag === 'workspace') {
return '--workspace <id|all> Connected Linear workspace id, or all'
}
if (command.startsWith('linear ') && flag === 'workspace') {
return '--workspace <id> Connected Linear workspace id'
}
if (command.startsWith('linear ') && flag === 'body') {
return '--body <text> Linear comment or issue body'
}
if (command.startsWith('linear ') && flag === 'body-file') {
return '--body-file <path|-> Read Linear body from a file or stdin'
}
if (command.startsWith('linear ') && flag === 'write-id') {
return '--write-id <uuid> Retry id from linear_write_unconfirmed'
}
if (command.startsWith('linear ') && flag === 'to') {
return '--to <state> Exact Linear workflow state name'
}
if (command === 'linear comment add' && flag === 'reply-to') {
return '--reply-to <id> Comment id to reply to'
}
if (command === 'linear attach' && flag === 'url') {
return '--url <url> Absolute http(s) link to attach'
}
if (command === 'linear attach' && flag === 'title') {
return '--title <text> Attachment title'
}
if (command === 'linear create' && flag === 'title') {
return '--title <text> New Linear issue title'
}
if (command === 'linear create' && flag === 'team') {
return '--team <key> Linear team key'
}
if (command === 'linear create' && flag === 'parent') {
return '--parent <id> Parent Linear issue key, id, or URL'
}
if (command === 'linear create' && flag === 'parent-current') {
return '--parent-current Use the current linked issue as parent'
}
if (flag === 'key' && command === 'computer hotkey') {
return '--key <key-combo> Modifier chord with one key, e.g. CmdOrCtrl+A'
}
+61
View File
@@ -196,6 +196,67 @@ describe('orca root help', () => {
expect(searchHelp).toContain('--query <text> Text to search across Linear issues')
expect(callMock).not.toHaveBeenCalled()
})
it('progressively discloses Linear commands', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['--help'], '/tmp/repo')
const rootHelp = String(logSpy.mock.calls[0][0])
expect(rootHelp).toContain('Linear:')
expect(rootHelp).toContain('linear Read Linear ticket context for agents')
expect(rootHelp).not.toContain('linear issue')
expect(rootHelp).not.toContain('linear search')
expect(rootHelp).not.toContain('linear status set')
logSpy.mockClear()
await main(['linear', '--help'], '/tmp/repo')
const groupHelp = String(logSpy.mock.calls[0][0])
expect(groupHelp).toContain('orca linear')
expect(groupHelp).toContain('issue')
expect(groupHelp).toContain('search')
expect(groupHelp).toContain('status set')
expect(groupHelp).toContain('comment add')
expect(groupHelp).toContain('attach')
expect(groupHelp).toContain('create')
expect(groupHelp).not.toContain('--comments')
expect(groupHelp).not.toContain('--attachments')
logSpy.mockClear()
await main(['linear', 'issue', '--help'], '/tmp/repo')
const issueHelp = String(logSpy.mock.calls[0][0])
expect(issueHelp).toContain('orca linear issue [<id>]')
expect(issueHelp).toContain('--comments Include threaded Linear comments')
expect(issueHelp).toContain('--attachments Include attachment metadata and URLs')
expect(issueHelp).toContain('--workspace <id> Connected Linear workspace id')
expect(issueHelp).toContain('--id <id> Linear issue key, id, or URL')
logSpy.mockClear()
await main(['linear', 'search', '--help'], '/tmp/repo')
const searchHelp = String(logSpy.mock.calls[0][0])
expect(searchHelp).toContain('orca linear search <query>')
expect(searchHelp).toContain('--workspace <id|all> Connected Linear workspace id, or all')
expect(searchHelp).toContain('--query <text> Text to search across Linear issues')
logSpy.mockClear()
await main(['linear', 'comment', 'add', '--help'], '/tmp/repo')
const commentHelp = String(logSpy.mock.calls[0][0])
expect(commentHelp).toContain('orca linear comment add [<id>]')
expect(commentHelp).toContain('--body-file <path|-> Read Linear body from a file or stdin')
expect(commentHelp).toContain('--write-id <uuid> Retry id from linear_write_unconfirmed')
logSpy.mockClear()
await main(['linear', 'create', '--help'], '/tmp/repo')
const createHelp = String(logSpy.mock.calls[0][0])
expect(createHelp).toContain('orca linear create --title <title>')
expect(createHelp).toContain('--parent-current Use the current linked issue as parent')
expect(callMock).not.toHaveBeenCalled()
})
})
describe('orca cli worktree awareness', () => {
+26 -1
View File
@@ -1,7 +1,11 @@
import type {
LinearAttachResult,
LinearCommentAddResult,
LinearCreateResult,
LinearIssueContextResult,
LinearSearchIssueSummary,
LinearSearchResult
LinearSearchResult,
LinearStatusSetResult
} from '../shared/linear-agent-access'
export function formatLinearIssue(result: LinearIssueContextResult): string {
@@ -44,6 +48,27 @@ export function formatLinearSearch(result: LinearSearchResult): string {
return result.issues.map(formatSearchRow).join('\n')
}
export function formatLinearStatusSet(result: LinearStatusSetResult): string {
const suffix = result.meta.alreadyInState ? ' (already set)' : ''
return `Set ${result.issue.identifier} to ${result.state.name}${suffix}.`
}
export function formatLinearCommentAdd(result: LinearCommentAddResult): string {
const suffix = result.meta.deduplicated ? ' (already posted)' : ''
return `Added comment ${result.comment.id} to ${result.issue.identifier}${suffix}.`
}
export function formatLinearAttach(result: LinearAttachResult): string {
const suffix = result.meta.deduplicated ? ' (already attached)' : ''
return `Attached ${result.attachment.title} to ${result.issue.identifier}${suffix}.`
}
export function formatLinearCreate(result: LinearCreateResult): string {
const parent = result.issue.parent ? ` under ${result.issue.parent.identifier}` : ''
const suffix = result.meta.deduplicated ? ' (already created)' : ''
return `Created ${result.issue.identifier}${parent}: ${result.issue.title}${suffix}.`
}
export function printLinearIssueWarnings(result: LinearIssueContextResult): void {
for (const error of result.meta.includeErrors) {
console.error(`warning: ${error.include} unavailable: ${error.message}`)
+67
View File
@@ -33,5 +33,72 @@ export const LINEAR_COMMAND_SPECS: CommandSpec[] = [
allowedFlags: [...GLOBAL_FLAGS, 'limit', 'workspace', 'query'],
positionalArgs: ['query'],
examples: ['orca linear search "auth bug"', 'orca linear search ENG --workspace all --json']
},
{
path: ['linear', 'status', 'set'],
summary: 'Set a Linear issue status',
usage: 'orca linear status set [<id>] [--current] --to <state> [--workspace <id>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'current', 'to', 'workspace', 'id'],
positionalArgs: ['id'],
examples: [
'orca linear status set ENG-123 --to "In Review"',
'orca linear status set --current --to Done --json'
]
},
{
path: ['linear', 'comment', 'add'],
summary: 'Add a comment to a Linear issue',
usage:
'orca linear comment add [<id>] [--current] (--body <text> | --body-file <path|->) [--reply-to <commentId>] [--write-id <uuid>] [--workspace <id>] [--json]',
allowedFlags: [
...GLOBAL_FLAGS,
'current',
'body',
'body-file',
'reply-to',
'write-id',
'workspace',
'id'
],
positionalArgs: ['id'],
examples: [
'orca linear comment add ENG-123 --body "Implementation is ready for review."',
'orca linear comment add --current --body-file - --json'
],
notes: ['Use --body-file - to read multiline comment bodies from stdin.']
},
{
path: ['linear', 'attach'],
summary: 'Attach a link to a Linear issue',
usage:
'orca linear attach [<id>] [--current] --url <url> [--title <title>] [--write-id <uuid>] [--workspace <id>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'current', 'url', 'title', 'write-id', 'workspace', 'id'],
positionalArgs: ['id'],
examples: [
'orca linear attach ENG-123 --url https://example.com/review/123 --title "PR/MR link"',
'orca linear attach --current --url https://example.com/review/123 --json'
]
},
{
path: ['linear', 'create'],
summary: 'Create a Linear issue',
usage:
'orca linear create --title <title> [--body <text> | --body-file <path|->] [--team <key>] [--parent <id> | --parent-current] [--write-id <uuid>] [--workspace <id>] [--json]',
allowedFlags: [
...GLOBAL_FLAGS,
'title',
'body',
'body-file',
'team',
'parent',
'parent-current',
'write-id',
'workspace'
],
examples: [
'orca linear create --title "Investigate flaky login" --team ENG',
'orca linear create --title "Follow-up bug" --parent-current --body-file - --json'
],
notes: ['Use --body-file - to read multiline issue bodies from stdin.']
}
]