mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 08:03:14 +00:00
Merge remote-tracking branch 'origin/main' into Jinwoo-H/feature-control-multiple-orca-servers-concurrent
This commit is contained in:
@@ -36,6 +36,7 @@ export const BOOLEAN_FLAGS = new Set([
|
||||
'mobile',
|
||||
'mobile-pairing',
|
||||
'no-pairing',
|
||||
'parent-current',
|
||||
'ready',
|
||||
'relations',
|
||||
'restore-window',
|
||||
|
||||
@@ -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
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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.']
|
||||
}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user