mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
* feat(linear): add MCP-style save issue * fix(linear): harden save issue parity * fix(linear): close save issue contract gaps * docs(linear): bundle project discovery with save issue
302 lines
10 KiB
TypeScript
302 lines
10 KiB
TypeScript
import type {
|
|
LinearIssueContextResult,
|
|
LinearIssueListResult,
|
|
LinearMcpIssueListResult,
|
|
LinearIssueTaskUpdateResult,
|
|
LinearIssueRelationWriteResult,
|
|
LinearProjectListResult,
|
|
LinearSearchIssueSummary,
|
|
LinearSearchResult,
|
|
LinearTeamListResult,
|
|
LinearTeamLabelsResult,
|
|
LinearTeamMembersResult,
|
|
LinearTeamStatesResult,
|
|
LinearStatusSetResult,
|
|
LinearCommentAddResult,
|
|
LinearAttachResult,
|
|
LinearCreateResult,
|
|
LinearSaveIssueResult
|
|
} from '../../shared/linear-agent-access'
|
|
import {
|
|
formatLinearProjectListRows,
|
|
linearProjectListWarningLines
|
|
} from '../../shared/linear-project-list-format'
|
|
import {
|
|
isLinearAttachResult,
|
|
isLinearCommentAddResult,
|
|
isLinearCreateResult,
|
|
isLinearSaveIssueResult,
|
|
isLinearIssueContextResult,
|
|
isLinearIssueListResult,
|
|
isLinearMcpIssueListResult,
|
|
isLinearProjectListResult,
|
|
isLinearSearchResult,
|
|
isLinearStatusSetResult,
|
|
isLinearTaskUpdateResult,
|
|
isLinearRelationWriteResult,
|
|
isLinearTeamLabelsResult,
|
|
isLinearTeamListResult,
|
|
isLinearTeamMembersResult,
|
|
isLinearTeamStatesResult
|
|
} from './ssh-remote-linear-result-guards'
|
|
|
|
export function formatRemoteLinearCli(result: unknown): { stdout: string; stderr: string } | null {
|
|
if (isLinearIssueContextResult(result)) {
|
|
return { stdout: `${formatLinearIssue(result)}\n`, stderr: linearIssueWarnings(result) }
|
|
}
|
|
if (isLinearSearchResult(result)) {
|
|
return {
|
|
stdout: `${formatLinearIssueRows(result.issues)}\n`,
|
|
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`,
|
|
stderr: linearListWarnings(result)
|
|
}
|
|
}
|
|
if (isLinearProjectListResult(result)) {
|
|
return {
|
|
stdout: `${formatLinearProjectListRows(result)}\n`,
|
|
stderr: linearProjectListWarnings(result)
|
|
}
|
|
}
|
|
if (isLinearTeamListResult(result)) {
|
|
return { stdout: `${formatLinearTeamList(result)}\n`, stderr: linearListWarnings(result) }
|
|
}
|
|
if (isLinearTeamMembersResult(result)) {
|
|
return { stdout: `${formatLinearTeamMembers(result)}\n`, stderr: '' }
|
|
}
|
|
if (isLinearTeamStatesResult(result)) {
|
|
return { stdout: `${formatLinearTeamStates(result)}\n`, stderr: '' }
|
|
}
|
|
if (isLinearTeamLabelsResult(result)) {
|
|
return { stdout: `${formatLinearTeamLabels(result)}\n`, stderr: '' }
|
|
}
|
|
if (isLinearStatusSetResult(result)) {
|
|
return { stdout: `${formatLinearStatusSet(result)}\n`, stderr: '' }
|
|
}
|
|
if (isLinearTaskUpdateResult(result)) {
|
|
return { stdout: `${formatLinearTaskUpdate(result)}\n`, stderr: '' }
|
|
}
|
|
if (isLinearRelationWriteResult(result)) {
|
|
return { stdout: `${formatLinearRelationWrite(result)}\n`, stderr: '' }
|
|
}
|
|
if (isLinearCommentAddResult(result)) {
|
|
return { stdout: `${formatLinearCommentAdd(result)}\n`, stderr: '' }
|
|
}
|
|
if (isLinearAttachResult(result)) {
|
|
return { stdout: `${formatLinearAttach(result)}\n`, stderr: '' }
|
|
}
|
|
if (isLinearSaveIssueResult(result)) {
|
|
return { stdout: `${formatLinearSaveIssue(result)}\n`, stderr: '' }
|
|
}
|
|
if (isLinearCreateResult(result)) {
|
|
return { stdout: `${formatLinearCreate(result)}\n`, stderr: '' }
|
|
}
|
|
return null
|
|
}
|
|
|
|
function formatLinearIssue(result: LinearIssueContextResult): string {
|
|
const issue = result.issue
|
|
const lines = [
|
|
`${issue.identifier} ${issue.title}`,
|
|
`URL: ${issue.url}`,
|
|
`State: ${issue.state?.name ?? 'unknown'}`,
|
|
`Assignee: ${issue.assignee?.displayName ?? 'unassigned'}`,
|
|
`Project: ${issue.project?.name ?? 'none'}`
|
|
]
|
|
lines.push(`Priority: ${formatPriority(issue.priority)}`)
|
|
lines.push(`Estimate: ${issue.estimate ?? 'none'}`)
|
|
if (issue.labels.length > 0) {
|
|
lines.push(
|
|
`Labels: ${issue.labels
|
|
.map((label) => label.name)
|
|
.filter(Boolean)
|
|
.join(', ')}`
|
|
)
|
|
}
|
|
if (issue.dueDate) {
|
|
lines.push(`Due: ${issue.dueDate}`)
|
|
}
|
|
for (const section of ['comments', 'children', 'attachments', 'relations', 'activity'] as const) {
|
|
const meta = result.meta.sections[section]
|
|
if (meta) {
|
|
lines.push(`${section[0].toUpperCase()}${section.slice(1)}: ${meta.returned}`)
|
|
}
|
|
}
|
|
if (result.inlineMedia?.length) {
|
|
lines.push(`Inline media: ${result.inlineMedia.length} (use --json for URLs)`)
|
|
}
|
|
return lines.join('\n')
|
|
}
|
|
|
|
function formatLinearSaveIssue(result: LinearSaveIssueResult): string {
|
|
return result.meta.created
|
|
? formatLinearCreate(result)
|
|
: `Saved ${result.issue.identifier}: ${result.issue.title}.`
|
|
}
|
|
|
|
function formatLinearIssueRows(issues: LinearSearchIssueSummary[]): string {
|
|
if (issues.length === 0) {
|
|
return 'No Linear issues found.'
|
|
}
|
|
return issues.map(formatLinearIssueRow).join('\n')
|
|
}
|
|
|
|
function formatLinearIssueRow(issue: LinearSearchIssueSummary): string {
|
|
const state = issue.state?.name ?? 'unknown'
|
|
const assignee = issue.assignee?.displayName ?? 'unassigned'
|
|
return `${issue.identifier.padEnd(10)} ${state.padEnd(14)} ${assignee.padEnd(18)} ${issue.title}`
|
|
}
|
|
|
|
function formatPriority(priority: number | null | undefined): string {
|
|
if (priority == null || priority === 0) {
|
|
return 'none'
|
|
}
|
|
switch (priority) {
|
|
case 1:
|
|
return 'urgent'
|
|
case 2:
|
|
return 'high'
|
|
case 3:
|
|
return 'medium'
|
|
case 4:
|
|
return 'low'
|
|
default:
|
|
return 'none'
|
|
}
|
|
}
|
|
|
|
function formatLinearTeamList(result: LinearTeamListResult): string {
|
|
if (result.teams.length === 0) {
|
|
return 'No Linear teams found.'
|
|
}
|
|
return result.teams
|
|
.map(
|
|
(team) =>
|
|
`${team.key.padEnd(10)} ${team.name}${team.workspace ? ` ${team.workspace.name}` : ''}`
|
|
)
|
|
.join('\n')
|
|
}
|
|
|
|
function formatLinearTeamMembers(result: LinearTeamMembersResult): string {
|
|
if (result.members.length === 0) {
|
|
return `No Linear members found for ${result.team.key}.`
|
|
}
|
|
return result.members
|
|
.map((member) => `${(member.displayName ?? 'unknown').padEnd(24)} ${member.id ?? ''}`)
|
|
.join('\n')
|
|
}
|
|
|
|
function formatLinearTeamStates(result: LinearTeamStatesResult): string {
|
|
if (result.states.length === 0) {
|
|
return `No Linear workflow states found for ${result.team.key}.`
|
|
}
|
|
return result.states
|
|
.map((state) => `${state.name.padEnd(24)} ${(state.type ?? '').padEnd(12)} ${state.id}`)
|
|
.join('\n')
|
|
}
|
|
|
|
function formatLinearTeamLabels(result: LinearTeamLabelsResult): string {
|
|
if (result.labels.length === 0) {
|
|
return `No Linear labels found for ${result.team.key}.`
|
|
}
|
|
return result.labels.map((label) => `${label.name.padEnd(24)} ${label.id}`).join('\n')
|
|
}
|
|
|
|
function formatLinearStatusSet(result: LinearStatusSetResult): string {
|
|
const suffix = result.meta.alreadyInState ? ' (already set)' : ''
|
|
return `Set ${result.issue.identifier} to ${result.state.name}${suffix}.`
|
|
}
|
|
|
|
function formatLinearTaskUpdate(result: LinearIssueTaskUpdateResult): string {
|
|
const suffix = result.meta.alreadySet ? ' (already set)' : ''
|
|
return `Updated ${result.issue.identifier} ${taskOperationLabel(result.operation)}${suffix}.`
|
|
}
|
|
|
|
function formatLinearRelationWrite(result: LinearIssueRelationWriteResult): string {
|
|
const verb = result.operation === 'add' ? 'Added' : 'Removed'
|
|
const suffix = result.meta.alreadySet
|
|
? result.operation === 'add'
|
|
? ' (already present)'
|
|
: ' (already absent)'
|
|
: ''
|
|
return `${verb} ${result.issue.identifier} ${result.relation.relationship} ${result.relatedIssue.identifier}${suffix}.`
|
|
}
|
|
|
|
function formatLinearCommentAdd(result: LinearCommentAddResult): string {
|
|
const suffix = result.meta.deduplicated ? ' (already posted)' : ''
|
|
return `Added comment ${result.comment.id} to ${result.issue.identifier}${suffix}.`
|
|
}
|
|
|
|
function formatLinearAttach(result: LinearAttachResult): string {
|
|
const suffix = result.meta.deduplicated ? ' (already attached)' : ''
|
|
return `Attached ${result.attachment.title} to ${result.issue.identifier}${suffix}.`
|
|
}
|
|
|
|
function formatLinearCreate(result: LinearCreateResult | LinearSaveIssueResult): string {
|
|
const parent = result.issue.parent ? ` under ${result.issue.parent.identifier}` : ''
|
|
const project = result.issue.project?.name ? ` in ${result.issue.project.name}` : ''
|
|
const suffix = result.meta.deduplicated ? ' (already created)' : ''
|
|
return `Created ${result.issue.identifier}${parent}${project}: ${result.issue.title}${suffix}.`
|
|
}
|
|
|
|
function taskOperationLabel(operation: LinearIssueTaskUpdateResult['operation']): string {
|
|
return operation === 'dueDate' ? 'due date' : operation
|
|
}
|
|
|
|
function linearIssueWarnings(result: LinearIssueContextResult): string {
|
|
const warnings = result.meta.includeErrors.map(
|
|
(error) => `warning: ${error.include} unavailable: ${error.message}`
|
|
)
|
|
for (const [name, meta] of Object.entries(result.meta.sections)) {
|
|
if (meta?.capReached) {
|
|
warnings.push(`warning: ${name} capped at ${meta.returned}/${meta.cap}`)
|
|
}
|
|
}
|
|
return warnings.length > 0 ? `${warnings.join('\n')}\n` : ''
|
|
}
|
|
|
|
function linearListWarnings(
|
|
result: LinearSearchResult | LinearIssueListResult | LinearTeamListResult,
|
|
label = 'Linear'
|
|
): string {
|
|
const warnings: string[] = []
|
|
const meta = result.meta
|
|
if ('hasMore' in meta && meta.hasMore) {
|
|
warnings.push(`warning: showing first ${meta.returned} Linear issues`)
|
|
}
|
|
if ('limitReached' in meta && meta.limitReached) {
|
|
warnings.push(`warning: showing first ${meta.returned} Linear issues`)
|
|
}
|
|
for (const error of meta.workspaceErrors ?? []) {
|
|
warnings.push(`warning: ${error.workspace.name} unavailable for ${label}: ${error.message}`)
|
|
}
|
|
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` : ''
|
|
}
|