mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
feat(linear): add MCP-compatible issue listing
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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')
|
||||
}
|
||||
@@ -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()))
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 } }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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] ?? '')
|
||||
}
|
||||
@@ -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,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')
|
||||
}
|
||||
@@ -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) &&
|
||||
|
||||
@@ -41,6 +41,7 @@ const REMOTE_BOOLEAN_FLAGS = new Set([
|
||||
'full',
|
||||
'help',
|
||||
'inject',
|
||||
'include-archived',
|
||||
'json',
|
||||
'me',
|
||||
'relations',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}[]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user