feat(linear): add MCP-compatible issue listing

This commit is contained in:
Brennan Benson
2026-07-21 13:14:32 -07:00
parent be066fe8e9
commit 2ddff4357c
24 changed files with 727 additions and 21 deletions
+3
View File
@@ -69,6 +69,7 @@ Do not use `orca linear attach` to read screenshots. That command creates link a
```bash
orca linear issue [<id>] [--current] [--comments] [--children] [--depth <n>] [--attachments] [--relations] [--activity] [--full] [--workspace <id>] [--json]
orca linear list-issues [--team <team>] [--cycle <cycle>] [--label <label>] [--limit <n>] [--query <text>] [--state <state>] [--cursor <cursor>] [--order-by createdAt|updatedAt] [--project <project>] [--release <release>] [--assignee <user|me|null>] [--delegate <user|me|null>] [--parent-id <issue|null>] [--priority <0-4>] [--created-at <datetime|duration>] [--updated-at <datetime|duration>] [--include-archived] [--workspace <id>|all] [--json]
orca linear search <query> [--limit <n>] [--workspace <id>|all] [--json]
orca linear team list [--workspace <id>|all] [--json]
orca linear team members --team <key|id> [--workspace <id>] [--json]
@@ -114,6 +115,8 @@ orca linear list --filter assigned --limit 10 --workspace all --json
orca linear list --filter open --team <key-or-id> --workspace <workspaceId> --json
```
Use `list-issues` when MCP-compatible filters or cursor pagination are needed. A cursor is workspace-specific, so combine `--cursor` with a concrete `--workspace` rather than `all`.
Prefer `label add` and `label remove` for incremental edits. `label set` replaces the full label set and should be used only when deliberate cleanup is intended.
## Completion Flow
+3
View File
@@ -69,6 +69,7 @@ Do not use `orca linear attach` to read screenshots. That command creates link a
```bash
orca linear issue [<id>] [--current] [--comments] [--children] [--depth <n>] [--attachments] [--relations] [--activity] [--full] [--workspace <id>] [--json]
orca linear list-issues [--team <team>] [--cycle <cycle>] [--label <label>] [--limit <n>] [--query <text>] [--state <state>] [--cursor <cursor>] [--order-by createdAt|updatedAt] [--project <project>] [--release <release>] [--assignee <user|me|null>] [--delegate <user|me|null>] [--parent-id <issue|null>] [--priority <0-4>] [--created-at <datetime|duration>] [--updated-at <datetime|duration>] [--include-archived] [--workspace <id>|all] [--json]
orca linear search <query> [--limit <n>] [--workspace <id>|all] [--json]
orca linear team list [--workspace <id>|all] [--json]
orca linear team members --team <key|id> [--workspace <id>] [--json]
@@ -114,6 +115,8 @@ orca linear list --filter assigned --limit 10 --workspace all --json
orca linear list --filter open --team <key-or-id> --workspace <workspaceId> --json
```
Use `list-issues` when MCP-compatible filters or cursor pagination are needed. A cursor is workspace-specific, so combine `--cursor` with a concrete `--workspace` rather than `all`.
Prefer `label add` and `label remove` for incremental edits. `label set` replaces the full label set and should be used only when deliberate cleanup is intended.
## Completion Flow
+1
View File
@@ -40,6 +40,7 @@ export const BOOLEAN_FLAGS = new Set([
'full',
'help',
'inject',
'include-archived',
'interrupt',
'json',
'messages',
File diff suppressed because one or more lines are too long
+53
View File
@@ -0,0 +1,53 @@
import type {
LinearMcpIssueListRequest,
LinearMcpIssueListResult
} from '../../shared/linear-agent-access'
import type { CommandHandler } from '../dispatch'
import {
getOptionalNonNegativeIntegerFlag,
getOptionalPositiveIntegerFlag,
getOptionalStringFlag
} from '../flags'
import { printResult } from '../format'
import { formatLinearMcpIssueList, printLinearMcpIssueListWarnings } from '../linear-format'
import { RuntimeClientError } from '../runtime-client'
export const runLinearListIssues: CommandHandler = async ({ flags, client, json }) => {
const priority = getOptionalNonNegativeIntegerFlag(flags, 'priority')
if (priority !== undefined && priority > 4) {
throw new RuntimeClientError('invalid_argument', '--priority must be between 0 and 4')
}
const request: LinearMcpIssueListRequest = {
team: getOptionalStringFlag(flags, 'team'),
cycle: getOptionalStringFlag(flags, 'cycle'),
label: getOptionalStringFlag(flags, 'label'),
limit: getOptionalPositiveIntegerFlag(flags, 'limit'),
query: getOptionalStringFlag(flags, 'query'),
state: getOptionalStringFlag(flags, 'state'),
cursor: getOptionalStringFlag(flags, 'cursor'),
orderBy: getOrderBy(flags),
project: getOptionalStringFlag(flags, 'project'),
release: getOptionalStringFlag(flags, 'release'),
assignee: getOptionalStringFlag(flags, 'assignee'),
delegate: getOptionalStringFlag(flags, 'delegate'),
parentId: getOptionalStringFlag(flags, 'parent-id'),
priority,
createdAt: getOptionalStringFlag(flags, 'created-at'),
updatedAt: getOptionalStringFlag(flags, 'updated-at'),
includeArchived: flags.get('include-archived') === true,
workspaceId: getOptionalStringFlag(flags, 'workspace')
}
const response = await client.call<LinearMcpIssueListResult>('linear.listIssues', request)
if (!json) {
printLinearMcpIssueListWarnings(response.result)
}
printResult(response, json, formatLinearMcpIssueList)
}
function getOrderBy(flags: Map<string, string | boolean>): LinearMcpIssueListRequest['orderBy'] {
const value = getOptionalStringFlag(flags, 'order-by')
if (value === undefined || value === 'createdAt' || value === 'updatedAt') {
return value
}
throw new RuntimeClientError('invalid_argument', '--order-by must be createdAt or updatedAt')
}
+36
View File
@@ -94,6 +94,42 @@ describe('orca linear CLI handlers', () => {
)
})
it('maps MCP-compatible list-issues filters and cursor pagination', async () => {
queueFixtures(callMock, okFixture('req_list', issueResult()))
await main(
[
'linear',
'list-issues',
'--team',
'ENG',
'--assignee',
'me',
'--priority',
'2',
'--cursor',
'next-page',
'--order-by',
'createdAt',
'--include-archived',
'--json'
],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith(
'linear.listIssues',
expect.objectContaining({
team: 'ENG',
assignee: 'me',
priority: 2,
cursor: 'next-page',
orderBy: 'createdAt',
includeArchived: true
})
)
})
it('keeps global boolean flags before Linear commands from consuming command tokens', async () => {
queueFixtures(callMock, okFixture('req_linear', issueResult()))
+2
View File
@@ -64,11 +64,13 @@ import {
printLinearProjectListWarnings,
printLinearSearchWarnings
} from '../linear-format'
import { runLinearListIssues } from './linear-list-issues'
const ISSUE_CONTEXT_TIMEOUT_MS = 120_000
const LINEAR_WRITE_TIMEOUT_MS = 75_000
export const LINEAR_HANDLERS: Record<string, CommandHandler> = {
'linear list-issues': runLinearListIssues,
'linear issue': async ({ flags, client, cwd, json }) => {
const request = buildIssueRequest(flags, cwd, client.isRemote)
const response = await client.call<LinearIssueContextResult>('linear.issueContext', request, {
+19
View File
@@ -3,6 +3,7 @@ import type {
LinearCommentAddResult,
LinearCreateResult,
LinearIssueListResult,
LinearMcpIssueListResult,
LinearIssueContextResult,
LinearIssueTaskUpdateResult,
LinearProjectListResult,
@@ -114,6 +115,24 @@ export function formatLinearIssueList(result: LinearIssueListResult): string {
return result.issues.map(formatSearchRow).join('\n')
}
export function formatLinearMcpIssueList(result: LinearMcpIssueListResult): string {
if (result.issues.length === 0) {
return 'No Linear issues found.'
}
return result.issues.map(formatSearchRow).join('\n')
}
export function printLinearMcpIssueListWarnings(result: LinearMcpIssueListResult): void {
if (result.meta.hasMore) {
console.error(
`warning: more results available; next cursor: ${result.meta.nextCursor ?? 'n/a'}`
)
}
for (const error of result.meta.workspaceErrors) {
console.error(`warning: ${error.workspace.name} unavailable for Linear: ${error.message}`)
}
}
export function formatLinearProjectList(result: LinearProjectListResult): string {
return formatLinearProjectListRows(result)
}
+32
View File
@@ -2,6 +2,38 @@ import type { CommandSpec } from '../args'
import { GLOBAL_FLAGS } from '../args'
export const LINEAR_COMMAND_SPECS: CommandSpec[] = [
{
path: ['linear', 'list-issues'],
summary: 'List Linear issues with MCP-compatible filters',
usage:
'orca linear list-issues [--team <team>] [--cycle <cycle>] [--label <label>] [--limit <n>] [--query <text>] [--state <state>] [--cursor <cursor>] [--order-by createdAt|updatedAt] [--project <project>] [--release <release>] [--assignee <user|me|null>] [--delegate <user|me|null>] [--parent-id <issue|null>] [--priority <0-4>] [--created-at <datetime|duration>] [--updated-at <datetime|duration>] [--include-archived] [--workspace <id>|all] [--json]',
allowedFlags: [
...GLOBAL_FLAGS,
'team',
'cycle',
'label',
'limit',
'query',
'state',
'cursor',
'order-by',
'project',
'release',
'assignee',
'delegate',
'parent-id',
'priority',
'created-at',
'updated-at',
'include-archived',
'workspace'
],
examples: [
'orca linear list-issues --team ENG --state started --assignee me --json',
'orca linear list-issues --query auth --updated-at -P7D --limit 100 --json',
'orca linear list-issues --cursor <cursor> --workspace <id> --json'
]
},
{
path: ['linear', 'issue'],
summary: 'Read Linear issue context for agents',
+99
View File
@@ -0,0 +1,99 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const rawRequest = vi.fn()
vi.mock('./client', () => ({
acquire: vi.fn(),
release: vi.fn(),
clearToken: vi.fn(),
isAuthError: () => false,
getClients: () => [
{
workspace: { id: 'workspace-1', organizationName: 'Acme' },
client: { client: { rawRequest } }
}
]
}))
describe('MCP-compatible Linear issue listing', () => {
beforeEach(() => vi.clearAllMocks())
it('passes rich filters, ordering, archive scope, and cursor to Linear', async () => {
rawRequest.mockResolvedValue({
data: {
issues: {
nodes: [
{
id: 'issue-1',
identifier: 'ENG-1',
title: 'Fix auth',
url: 'https://linear.app/acme/issue/ENG-1',
labels: { nodes: [] },
createdAt: '2026-07-01T00:00:00.000Z',
updatedAt: '2026-07-20T00:00:00.000Z'
}
],
pageInfo: { hasNextPage: true, endCursor: 'next-page' }
}
}
})
const { listMcpIssues } = await import('./mcp-issue-list')
const result = await listMcpIssues({
team: 'ENG',
label: 'Bug',
state: 'In Progress',
assignee: 'me',
project: 'Launch',
priority: 2,
query: 'auth',
updatedAt: '-P7D',
cursor: 'current-page',
orderBy: 'createdAt',
includeArchived: true,
limit: 100,
workspaceId: 'workspace-1'
})
expect(rawRequest).toHaveBeenCalledWith(
expect.stringContaining('query OrcaLinearListIssues'),
expect.objectContaining({
first: 100,
after: 'current-page',
orderBy: 'createdAt',
includeArchived: true,
filter: expect.objectContaining({
searchableContent: { contains: 'auth' },
priority: { eq: 2 },
updatedAt: { gte: '-P7D' },
assignee: { isMe: { eq: true } }
})
})
)
expect(result.meta).toMatchObject({
limit: 100,
returned: 1,
hasMore: true,
nextCursor: 'next-page',
orderBy: 'createdAt'
})
expect(result.issues[0]).toMatchObject({
identifier: 'ENG-1',
workspace: { id: 'workspace-1', name: 'Acme' }
})
})
it('uses null filters and clamps direct callers to the MCP maximum', async () => {
rawRequest.mockResolvedValue({
data: { issues: { nodes: [], pageInfo: { hasNextPage: false } } }
})
const { listMcpIssues } = await import('./mcp-issue-list')
await listMcpIssues({ assignee: 'null', parentId: 'null', limit: 999 })
expect(rawRequest.mock.calls[0]?.[1]).toMatchObject({
first: 250,
filter: { assignee: { null: true }, parent: { null: true } }
})
})
})
+208
View File
@@ -0,0 +1,208 @@
import type {
LinearMcpIssueListRequest,
LinearMcpIssueListResult
} from '../../shared/linear-agent-access'
import { acquire, clearToken, getClients, isAuthError, release } from './client'
import { classifyLinearError, linearMessage } from './issue-context-errors'
import { ISSUE_FIELDS, mapIssue, type RawIssue } from './issue-context-raw'
const LIST_ISSUES_DEFAULT_LIMIT = 50
const LIST_ISSUES_MAX_LIMIT = 250
type RawListIssuesResponse = {
issues?: {
nodes?: RawIssue[]
pageInfo?: { hasNextPage?: boolean; endCursor?: string | null }
} | null
}
const LIST_ISSUES_QUERY = `
query OrcaLinearListIssues(
$first: Int!
$after: String
$filter: IssueFilter
$orderBy: PaginationOrderBy
$includeArchived: Boolean
) {
issues(
first: $first
after: $after
filter: $filter
orderBy: $orderBy
includeArchived: $includeArchived
) {
nodes { ${ISSUE_FIELDS} }
pageInfo { hasNextPage endCursor }
}
}
`
export async function listMcpIssues(
request: LinearMcpIssueListRequest
): Promise<LinearMcpIssueListResult> {
if (request.cursor && request.workspaceId === 'all') {
throw new Error('Cursor pagination requires a concrete Linear workspace.')
}
const limit = clampLimit(request.limit)
const orderBy = request.orderBy ?? 'updatedAt'
const entries = getClients(request.workspaceId)
const issues: LinearMcpIssueListResult['issues'] = []
const workspaceErrors: LinearMcpIssueListResult['meta']['workspaceErrors'] = []
let hasMore = false
let nextCursor: string | undefined
for (const entry of entries) {
await acquire()
try {
const raw = await entry.client.client.rawRequest<
RawListIssuesResponse,
Record<string, unknown>
>(LIST_ISSUES_QUERY, {
first: limit,
after: request.cursor,
filter: buildIssueFilter(request),
orderBy,
includeArchived: request.includeArchived ?? false
})
const connection = raw.data?.issues
issues.push(
...(connection?.nodes ?? []).map((issue) => ({
...mapIssue(issue),
workspace: { id: entry.workspace.id, name: entry.workspace.organizationName }
}))
)
hasMore ||= connection?.pageInfo?.hasNextPage === true
if (entries.length === 1) {
nextCursor = connection?.pageInfo?.endCursor ?? undefined
}
} catch (error) {
if (isAuthError(error)) {
clearToken(entry.workspace.id)
}
if (request.workspaceId !== 'all') {
throw error
}
workspaceErrors.push({
workspace: { id: entry.workspace.id, name: entry.workspace.organizationName },
code: classifyLinearError(error),
message: linearMessage(error)
})
} finally {
release()
}
}
issues.sort((left, right) => compareIssues(left, right, orderBy))
if (issues.length > limit) {
hasMore = true
issues.length = limit
}
return {
issues,
meta: {
limit,
returned: issues.length,
hasMore,
...(hasMore && nextCursor ? { nextCursor } : {}),
orderBy,
workspaceId: request.workspaceId,
partial: workspaceErrors.length > 0,
workspaceErrors
}
}
}
function buildIssueFilter(request: LinearMcpIssueListRequest): Record<string, unknown> {
const filter: Record<string, unknown> = {}
if (request.team) {
filter.team = namedFilter(request.team, true)
}
if (request.cycle) {
filter.cycle = nullableNamedFilter(request.cycle)
}
if (request.label) {
filter.labels = { some: namedFilter(request.label) }
}
if (request.query) {
filter.searchableContent = { contains: request.query }
}
if (request.state) {
filter.state = namedFilter(request.state)
}
if (request.project) {
filter.project = nullableNamedFilter(request.project)
}
if (request.release) {
filter.releases = { some: namedFilter(request.release, false, true) }
}
if (request.assignee) {
filter.assignee = nullableUserFilter(request.assignee)
}
if (request.delegate) {
filter.delegate = nullableUserFilter(request.delegate)
}
if (request.parentId) {
filter.parent = nullableIdFilter(request.parentId)
}
if (request.priority !== undefined) {
filter.priority = { eq: request.priority }
}
if (request.createdAt) {
filter.createdAt = { gte: request.createdAt }
}
if (request.updatedAt) {
filter.updatedAt = { gte: request.updatedAt }
}
return filter
}
function namedFilter(value: string, includeKey = false, includeVersion = false): object {
return {
or: [
{ id: { eq: value } },
{ name: { eqIgnoreCase: value } },
...(includeKey ? [{ key: { eqIgnoreCase: value } }] : []),
...(includeVersion ? [{ version: { eqIgnoreCase: value } }] : [])
]
}
}
function nullableNamedFilter(value: string): object {
return value === 'null' ? { null: true } : namedFilter(value)
}
function nullableIdFilter(value: string): object {
return value === 'null' ? { null: true } : { id: { eq: value } }
}
function nullableUserFilter(value: string): object {
if (value === 'null') {
return { null: true }
}
if (value.toLocaleLowerCase() === 'me') {
return { isMe: { eq: true } }
}
return {
or: [
{ id: { eq: value } },
{ displayName: { eqIgnoreCase: value } },
{ name: { eqIgnoreCase: value } },
{ email: { eqIgnoreCase: value } }
]
}
}
function clampLimit(limit: number | undefined): number {
return Math.min(
Math.max(1, Math.floor(limit ?? LIST_ISSUES_DEFAULT_LIMIT)),
LIST_ISSUES_MAX_LIMIT
)
}
function compareIssues(
left: LinearMcpIssueListResult['issues'][number],
right: LinearMcpIssueListResult['issues'][number],
orderBy: 'createdAt' | 'updatedAt'
): number {
return (right[orderBy] ?? '').localeCompare(left[orderBy] ?? '')
}
+11
View File
@@ -178,6 +178,8 @@ import type {
LinearIssueRequest,
LinearIssueTaskUpdateRequest,
LinearIssueTaskUpdateResult,
LinearMcpIssueListRequest,
LinearMcpIssueListResult,
LinearTeamLabelsResult,
LinearTeamListResult,
LinearTeamMembersResult,
@@ -565,6 +567,7 @@ import {
linearMessage,
sanitizeLinearErrorMessage
} from '../linear/issue-context-errors'
import { listMcpIssues } from '../linear/mcp-issue-list'
import {
createProject as createLinearProject,
getCustomView as getLinearCustomView,
@@ -24845,6 +24848,14 @@ export class OrcaRuntimeService {
}
}
async linearMcpIssueList(params: LinearMcpIssueListRequest): Promise<LinearMcpIssueListResult> {
try {
return await listMcpIssues(params)
} catch (error) {
throw this.mapLinearReadFailure(error)
}
}
async linearResolveCurrentIssue(
context?: LinearCurrentIssueContextHints
): Promise<ReturnType<typeof getLinearCurrentIssueFromWorktree>> {
@@ -0,0 +1,52 @@
import { z } from 'zod'
import { defineMethod } from '../core'
import { OptionalFiniteNumber, OptionalString } from '../schemas'
import { LinearIssueAttributeFilterSchema } from './linear-issue-attribute-filter-schema'
const LegacyListIssues = z
.object({
filter: z.enum(['assigned', 'created', 'all', 'completed']).optional(),
limit: OptionalFiniteNumber,
workspaceId: OptionalString,
attributeFilter: LinearIssueAttributeFilterSchema.optional()
})
.strict()
.optional()
const McpListIssues = z
.object({
team: OptionalString,
cycle: OptionalString,
label: OptionalString,
limit: z.number().int().min(1).max(250).optional(),
query: OptionalString,
state: OptionalString,
cursor: OptionalString,
orderBy: z.enum(['createdAt', 'updatedAt']).optional(),
project: OptionalString,
release: OptionalString,
assignee: OptionalString,
delegate: OptionalString,
parentId: OptionalString,
priority: z.number().int().min(0).max(4).optional(),
createdAt: OptionalString,
updatedAt: OptionalString,
includeArchived: z.boolean(),
workspaceId: z.union([z.string(), z.literal('all')]).optional()
})
.strict()
const ListIssues = z.union([McpListIssues, LegacyListIssues])
export const LINEAR_ISSUE_LIST_METHOD = defineMethod({
name: 'linear.listIssues',
params: ListIssues,
handler: async (params, { runtime }) => {
if (params && 'includeArchived' in params) {
return runtime.linearMcpIssueList(params)
}
return runtime.linearListIssues(params?.filter, params?.limit, params?.workspaceId, {
attributeFilter: params?.attributeFilter
})
}
})
@@ -38,6 +38,7 @@ describe('linear RPC methods', () => {
getRuntimeId: () => 'test-runtime',
linearSearchIssues: vi.fn().mockResolvedValue([{ id: 'issue-1' }]),
linearListIssues: vi.fn().mockResolvedValue({ items: [{ id: 'issue-2' }], hasMore: true }),
linearMcpIssueList: vi.fn().mockResolvedValue({ issues: [], meta: {} }),
linearGetIssue: vi.fn().mockResolvedValue({ id: 'issue-3' }),
linearCreateIssue: vi.fn().mockResolvedValue({ ok: true, id: 'issue-4' }),
linearUpdateIssue: vi.fn().mockResolvedValue({ ok: true }),
@@ -56,6 +57,16 @@ describe('linear RPC methods', () => {
workspaceId: 'workspace-1'
})
)
await dispatcher.dispatch(
makeRequest('linear.listIssues', {
team: 'ENG',
assignee: 'me',
cursor: 'next',
orderBy: 'updatedAt',
includeArchived: false,
workspaceId: 'workspace-1'
})
)
await dispatcher.dispatch(
makeRequest('linear.getIssue', { id: 'issue-3', workspaceId: 'workspace-1' })
)
@@ -105,6 +116,14 @@ describe('linear RPC methods', () => {
expect(runtime.linearListIssues).toHaveBeenCalledWith('assigned', 20, 'workspace-1', {
attributeFilter: undefined
})
expect(runtime.linearMcpIssueList).toHaveBeenCalledWith({
team: 'ENG',
assignee: 'me',
cursor: 'next',
orderBy: 'updatedAt',
includeArchived: false,
workspaceId: 'workspace-1'
})
expect(runtime.linearGetIssue).toHaveBeenCalledWith('issue-3', 'workspace-1')
expect(runtime.linearCreateIssue).toHaveBeenCalledWith(
'team-1',
+2 -20
View File
@@ -2,9 +2,8 @@ import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas'
import { LINEAR_PROJECT_CREATE_METHOD } from './linear-project-create'
import { LinearIssueAttributeFilterSchema } from './linear-issue-attribute-filter-schema'
import { LINEAR_ISSUE_LIST_METHOD } from './linear-issue-list-method'
const VALID_FILTERS = ['assigned', 'created', 'all', 'completed'] as const
const VALID_CUSTOM_VIEW_MODELS = ['issue', 'project'] as const
const LinearPriority = z.number().int().min(0).max(4).optional()
const LinearLabelIds = z.array(requiredString('Invalid label ID')).optional()
@@ -34,16 +33,6 @@ const SearchIssues = z.object({
workspaceId: OptionalString
})
const ListIssues = z
.object({
filter: z.enum(VALID_FILTERS).optional(),
limit: OptionalFiniteNumber,
workspaceId: OptionalString,
attributeFilter: LinearIssueAttributeFilterSchema.optional()
})
.strict()
.optional()
const CreateIssue = z.object({
teamId: requiredString('Team ID is required'),
title: requiredString('Title is required'),
@@ -163,14 +152,7 @@ export const LINEAR_METHODS: RpcMethod[] = [
handler: async (params, { runtime }) =>
runtime.linearSearchIssues(params.query, params.limit, params.workspaceId)
}),
defineMethod({
name: 'linear.listIssues',
params: ListIssues,
handler: async (params, { runtime }) =>
runtime.linearListIssues(params?.filter, params?.limit, params?.workspaceId, {
attributeFilter: params?.attributeFilter
})
}),
LINEAR_ISSUE_LIST_METHOD,
defineMethod({
name: 'linear.createIssue',
params: CreateIssue,
@@ -0,0 +1,74 @@
import type { LinearMcpIssueListRequest } from '../../shared/linear-agent-access'
import type { RpcDispatcher } from '../runtime/rpc/dispatcher'
import type { RpcResponse } from '../runtime/rpc/core'
import { RemoteCliArgumentError, type ParsedRemoteCli } from './ssh-remote-cli-argument-error'
import { LINEAR_MCP_ISSUE_LIST_FLAGS } from './ssh-remote-linear-read-flags'
export async function dispatchRemoteLinearListIssues(
dispatcher: RpcDispatcher,
parsed: ParsedRemoteCli
): Promise<RpcResponse> {
for (const flag of parsed.flags.keys()) {
if (!LINEAR_MCP_ISSUE_LIST_FLAGS.has(flag)) {
throw new RemoteCliArgumentError('invalid_argument', `Unknown flag: --${flag}`)
}
}
if (parsed.commandPath.length !== 2) {
throw new RemoteCliArgumentError('invalid_argument', 'list-issues does not accept positionals')
}
const priority = optionalInteger(parsed.flags, 'priority')
if (priority !== undefined && (priority < 0 || priority > 4)) {
throw new RemoteCliArgumentError('invalid_argument', '--priority must be between 0 and 4')
}
const request: LinearMcpIssueListRequest = {
team: optionalString(parsed.flags, 'team'),
cycle: optionalString(parsed.flags, 'cycle'),
label: optionalString(parsed.flags, 'label'),
limit: optionalInteger(parsed.flags, 'limit'),
query: optionalString(parsed.flags, 'query'),
state: optionalString(parsed.flags, 'state'),
cursor: optionalString(parsed.flags, 'cursor'),
orderBy: orderBy(parsed.flags),
project: optionalString(parsed.flags, 'project'),
release: optionalString(parsed.flags, 'release'),
assignee: optionalString(parsed.flags, 'assignee'),
delegate: optionalString(parsed.flags, 'delegate'),
parentId: optionalString(parsed.flags, 'parent-id'),
priority,
createdAt: optionalString(parsed.flags, 'created-at'),
updatedAt: optionalString(parsed.flags, 'updated-at'),
includeArchived: parsed.flags.get('include-archived') === true,
workspaceId: optionalString(parsed.flags, 'workspace')
}
return await dispatcher.dispatch({
id: `remote-cli-${Date.now()}`,
authToken: 'remote-cli',
method: 'linear.listIssues',
params: request
})
}
function optionalString(flags: Map<string, string | boolean>, name: string): string | undefined {
const value = flags.get(name)
return typeof value === 'string' && value ? value : undefined
}
function optionalInteger(flags: Map<string, string | boolean>, name: string): number | undefined {
const raw = optionalString(flags, name)
if (raw === undefined) {
return undefined
}
const value = Number(raw)
if (!Number.isInteger(value) || value < 0) {
throw new RemoteCliArgumentError('invalid_argument', `--${name} must be an integer`)
}
return value
}
function orderBy(flags: Map<string, string | boolean>): LinearMcpIssueListRequest['orderBy'] {
const value = optionalString(flags, 'order-by')
if (value === undefined || value === 'createdAt' || value === 'updatedAt') {
return value
}
throw new RemoteCliArgumentError('invalid_argument', '--order-by must be createdAt or updatedAt')
}
+20
View File
@@ -1,6 +1,7 @@
import type {
LinearIssueContextResult,
LinearIssueListResult,
LinearMcpIssueListResult,
LinearIssueTaskUpdateResult,
LinearProjectListResult,
LinearSearchIssueSummary,
@@ -24,6 +25,7 @@ import {
isLinearCreateResult,
isLinearIssueContextResult,
isLinearIssueListResult,
isLinearMcpIssueListResult,
isLinearProjectListResult,
isLinearSearchResult,
isLinearStatusSetResult,
@@ -44,6 +46,12 @@ export function formatRemoteLinearCli(result: unknown): { stdout: string; stderr
stderr: linearListWarnings(result, 'Linear search')
}
}
if (isLinearMcpIssueListResult(result)) {
return {
stdout: `${formatLinearIssueRows(result.issues)}\n`,
stderr: linearMcpListWarnings(result)
}
}
if (isLinearIssueListResult(result)) {
return {
stdout: `${formatLinearIssueRows(result.issues)}\n`,
@@ -249,6 +257,18 @@ function linearListWarnings(
return warnings.length > 0 ? `${warnings.join('\n')}\n` : ''
}
function linearMcpListWarnings(result: LinearMcpIssueListResult): string {
const warnings = result.meta.workspaceErrors.map(
(error) => `warning: ${error.workspace.name} unavailable for Linear: ${error.message}`
)
if (result.meta.hasMore) {
warnings.unshift(
`warning: more results available; next cursor: ${result.meta.nextCursor ?? 'n/a'}`
)
}
return warnings.length > 0 ? `${warnings.join('\n')}\n` : ''
}
function linearProjectListWarnings(result: LinearProjectListResult): string {
const warnings = linearProjectListWarningLines(result)
return warnings.length > 0 ? `${warnings.join('\n')}\n` : ''
@@ -7,6 +7,7 @@ import {
import type { RpcDispatcher } from '../runtime/rpc/dispatcher'
import type { RpcResponse } from '../runtime/rpc/core'
import { RemoteCliArgumentError, type ParsedRemoteCli } from './ssh-remote-cli-argument-error'
import { dispatchRemoteLinearListIssues } from './ssh-remote-linear-list-issues'
import {
LINEAR_ISSUE_FLAGS,
@@ -22,6 +23,9 @@ export async function tryDispatchRemoteLinearReadCli(
parsed: ParsedRemoteCli,
env: Record<string, string>
): Promise<RpcResponse | null> {
if (isRemoteCommand(parsed, 'linear', 'list-issues')) {
return await dispatchRemoteLinearListIssues(dispatcher, parsed)
}
if (isRemoteCommand(parsed, 'linear', 'issue')) {
validateLinearRemoteArgs(parsed, {
command: ['linear', 'issue'],
@@ -57,3 +57,27 @@ export const LINEAR_LIST_FLAGS = new Set([
'limit',
'workspace'
])
export const LINEAR_MCP_ISSUE_LIST_FLAGS = new Set([
'help',
'json',
'pairing-code',
'environment',
'team',
'cycle',
'label',
'limit',
'query',
'state',
'cursor',
'order-by',
'project',
'release',
'assignee',
'delegate',
'parent-id',
'priority',
'created-at',
'updated-at',
'include-archived',
'workspace'
])
@@ -5,6 +5,9 @@ export function getRemoteLinearReadHelp(commandPath: string[]): string | null {
if (matchesRemoteCommand(commandPath, 'linear', 'issue')) {
return LINEAR_ISSUE_HELP
}
if (matchesRemoteCommand(commandPath, 'linear', 'list-issues')) {
return LINEAR_MCP_ISSUE_LIST_HELP
}
if (matchesRemoteCommand(commandPath, 'linear', 'search')) {
return LINEAR_SEARCH_HELP
}
@@ -41,6 +44,7 @@ const LINEAR_HELP = `orca linear
Usage: orca linear <command> [options]
Commands:
list-issues List Linear issues with MCP-compatible filters
issue Read Linear issue context for agents
search Search connected Linear workspaces
team list List connected Linear teams
@@ -94,6 +98,12 @@ Examples:
$ orca linear issue --current --comments
$ orca linear issue https://linear.app/acme/issue/ENG-123 --full --json`
const LINEAR_MCP_ISSUE_LIST_HELP = `orca linear list-issues
Usage: orca linear list-issues [--team <team>] [--cycle <cycle>] [--label <label>] [--limit <n>] [--query <text>] [--state <state>] [--cursor <cursor>] [--order-by createdAt|updatedAt] [--project <project>] [--release <release>] [--assignee <user|me|null>] [--delegate <user|me|null>] [--parent-id <issue|null>] [--priority <0-4>] [--created-at <datetime|duration>] [--updated-at <datetime|duration>] [--include-archived] [--workspace <id>|all] [--json]
List Linear issues with MCP-compatible filters and cursor pagination`
const LINEAR_SEARCH_HELP = `orca linear search
Usage: orca linear search <query> [--limit <n>] [--workspace <id>|all] [--json]
@@ -4,6 +4,7 @@ import type {
LinearCreateResult,
LinearIssueContextResult,
LinearIssueListResult,
LinearMcpIssueListResult,
LinearIssueTaskUpdateResult,
LinearProjectListResult,
LinearSearchResult,
@@ -46,6 +47,16 @@ export function isLinearIssueListResult(result: unknown): result is LinearIssueL
)
}
export function isLinearMcpIssueListResult(result: unknown): result is LinearMcpIssueListResult {
return (
isRecord(result) &&
Array.isArray(result.issues) &&
isRecord(result.meta) &&
typeof result.meta.orderBy === 'string' &&
Array.isArray(result.meta.workspaceErrors)
)
}
export function isLinearProjectListResult(result: unknown): result is LinearProjectListResult {
return (
isRecord(result) &&
+1
View File
@@ -41,6 +41,7 @@ const REMOTE_BOOLEAN_FLAGS = new Set([
'full',
'help',
'inject',
'include-archived',
'json',
'me',
'relations',
+1
View File
@@ -94,6 +94,7 @@ export type {
} from './linear-agent-result-types'
export type { LinearIssueActivityEntry, LinearIssueActivityValue } from './linear-issue-activity'
export type { LinearInlineMedia } from './linear-inline-media'
export type { LinearMcpIssueListRequest, LinearMcpIssueListResult } from './linear-mcp-issue-list'
export type LinearWriteTargetRequest = {
input?: string
+41
View File
@@ -0,0 +1,41 @@
import type { LinearErrorCode } from './linear-agent-access'
import type { LinearIssueSummary, LinearWorkspaceCandidate } from './linear-agent-result-types'
export type LinearMcpIssueListRequest = {
team?: string
cycle?: string
label?: string
limit?: number
query?: string
state?: string
cursor?: string
orderBy?: 'createdAt' | 'updatedAt'
project?: string
release?: string
assignee?: string
delegate?: string
parentId?: string
priority?: number
createdAt?: string
updatedAt?: string
includeArchived?: boolean
workspaceId?: string | 'all'
}
export type LinearMcpIssueListResult = {
issues: (LinearIssueSummary & { workspace: LinearWorkspaceCandidate })[]
meta: {
limit: number
returned: number
hasMore: boolean
nextCursor?: string
orderBy: 'createdAt' | 'updatedAt'
workspaceId?: string | 'all'
partial: boolean
workspaceErrors: {
workspace: LinearWorkspaceCandidate
code: LinearErrorCode
message: string
}[]
}
}