Add read-only orca linear CLI with trusted launch-prompt pointer (V1) (#5126)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson
2026-06-10 20:20:50 -07:00
committed by GitHub
co-authored by Orca
parent 519cc5d1c3
commit cdc0ca5e53
68 changed files with 4379 additions and 283 deletions
+208
View File
@@ -0,0 +1,208 @@
export const LINEAR_SEARCH_DEFAULT_LIMIT = 20
export const LINEAR_SEARCH_MAX_LIMIT = 50
export const LINEAR_COMMENTS_CAP = 500
export const LINEAR_COMMENT_BODY_CAP = 20_000
export const LINEAR_CHILDREN_DEFAULT_DEPTH = 2
export const LINEAR_CHILDREN_MAX_DEPTH = 5
export const LINEAR_CHILDREN_NODE_CAP = 200
export const LINEAR_ATTACHMENTS_CAP = 100
export const LINEAR_RELATIONS_CAP = 100
export const LINEAR_ERROR_CODES = [
'linear_not_connected',
'linear_issue_required',
'linear_no_linked_issue',
'linear_current_ambiguous',
'linear_issue_not_found',
'linear_workspace_ambiguous',
'linear_invalid_workspace',
'linear_rate_limited',
'linear_timeout',
'linear_permission_denied',
'linear_auth_expired',
'linear_network_error',
'linear_partial'
] as const
export type LinearErrorCode = (typeof LINEAR_ERROR_CODES)[number]
export type LinearIssueInclude = 'comments' | 'children' | 'attachments' | 'relations'
export type LinearIncludeErrorCode =
| 'linear_timeout'
| 'linear_rate_limited'
| 'linear_permission_denied'
| 'linear_auth_expired'
| 'linear_network_error'
| 'linear_include_failed'
export type LinearIssueRequest = {
input?: string
current?: boolean
workspaceId?: string
include: Record<LinearIssueInclude, boolean>
depth: number
context?: LinearCurrentIssueContextHints
}
export type LinearCurrentIssueContextHints = {
worktreeId?: string
terminalHandle?: string
cwd?: string
remote?: boolean
}
export type LinearIssueSummary = {
id: string
identifier: string
title: string
url: string
description?: string | null
state?: LinearNamedEntity | null
team?: (LinearNamedEntity & { key?: string | null }) | null
project?: LinearNamedEntity | null
cycle?: LinearNamedEntity | null
assignee?: LinearUserSummary | null
labels: LinearNamedEntity[]
priority?: number | null
estimate?: number | null
branchName?: string | null
createdAt?: string | null
updatedAt?: string | null
}
export type LinearNamedEntity = {
id?: string | null
name?: string | null
color?: string | null
type?: string | null
}
export type LinearUserSummary = {
id?: string | null
displayName?: string | null
avatarUrl?: string | null
}
export type LinearIssueCommentNode = {
id: string
body: string
bodyTruncated: boolean
createdAt?: string | null
updatedAt?: string | null
parentId?: string | null
user?: LinearUserSummary | null
}
export type LinearIssueChildNode = LinearIssueSummary & {
children?: LinearIssueChildNode[]
mayHaveMore?: boolean
}
export type LinearIssueAttachment = {
id: string
title?: string | null
url?: string | null
source?: string | null
subtitle?: string | null
createdAt?: string | null
metadataOnly: true
}
export type LinearIssueRelation = {
id: string
type?: string | null
relatedIssue?: Pick<LinearIssueSummary, 'id' | 'identifier' | 'title' | 'url'> | null
}
export type LinearCollectionMeta = {
returned: number
cap: number
capReached: boolean
hasMore?: boolean
mayHaveMore?: boolean
}
export type LinearIssueContextResult = {
issue: LinearIssueSummary
comments?: LinearIssueCommentNode[]
children?: LinearIssueChildNode[]
attachments?: LinearIssueAttachment[]
relations?: LinearIssueRelation[]
meta: {
requested: {
id?: string
current: boolean
workspaceId?: string
include: Record<LinearIssueInclude, boolean>
depth: number
}
resolved: {
id: string
identifier: string
workspaceId: string
workspaceName: string
worktreeId?: string
worktreePath?: string
}
partial: boolean
includeErrors: {
include: LinearIssueInclude
code: LinearIncludeErrorCode
message: string
}[]
sections: Partial<Record<LinearIssueInclude, LinearCollectionMeta>>
}
}
export type LinearSearchIssueSummary = Pick<
LinearIssueSummary,
'id' | 'identifier' | 'title' | 'url' | 'state' | 'team' | 'project' | 'assignee' | 'updatedAt'
> & {
workspace: {
id: string
name: string
}
}
export type LinearSearchResult = {
issues: LinearSearchIssueSummary[]
meta: {
query: string
workspaceId?: string | 'all'
limit: number
returned: number
limitReached: boolean
partial: boolean
workspaceErrors: {
workspace: LinearWorkspaceCandidate
code: LinearErrorCode
message: string
}[]
}
}
export type LinearWorkspaceCandidate = {
id: string
name: string
}
export function clampLinearSearchLimit(limit: number | undefined): number {
if (limit === undefined) {
return LINEAR_SEARCH_DEFAULT_LIMIT
}
if (!Number.isFinite(limit)) {
return LINEAR_SEARCH_DEFAULT_LIMIT
}
return Math.min(Math.max(1, Math.floor(limit)), LINEAR_SEARCH_MAX_LIMIT)
}
export function clampLinearIssueDepth(depth: number | undefined): number {
if (depth === undefined) {
return LINEAR_CHILDREN_DEFAULT_DEPTH
}
if (!Number.isFinite(depth)) {
return LINEAR_CHILDREN_DEFAULT_DEPTH
}
return Math.min(Math.max(0, Math.floor(depth)), LINEAR_CHILDREN_MAX_DEPTH)
}
+18 -1
View File
@@ -4,7 +4,8 @@ import {
buildLinearPersonalApiKeySettingsUrl,
buildLinearTeamUrl,
buildLinearWorkspaceApiSettingsUrl,
getLinearOrganizationUrlKeyFromIssueUrl
getLinearOrganizationUrlKeyFromIssueUrl,
parseLinearIssueInput
} from './linear-links'
describe('linear links', () => {
@@ -41,4 +42,20 @@ describe('linear links', () => {
)
expect(buildLinearWorkspaceApiSettingsUrl(' ')).toBe('https://linear.app/settings/api')
})
it('parses bare Linear issue identifiers', () => {
expect(parseLinearIssueInput('eng-123')).toEqual({ identifier: 'ENG-123' })
})
it('parses Linear issue URLs with organization URL keys', () => {
expect(parseLinearIssueInput('https://linear.app/acme/issue/eng-123/fix-auth')).toEqual({
identifier: 'ENG-123',
organizationUrlKey: 'acme'
})
})
it('rejects non-Linear issue input', () => {
expect(parseLinearIssueInput('https://example.com/acme/issue/ENG-123')).toBeNull()
expect(parseLinearIssueInput('not an issue')).toBeNull()
})
})
+42
View File
@@ -38,3 +38,45 @@ export function getLinearOrganizationUrlKeyFromIssueUrl(issueUrl?: string | null
return null
}
}
export type ParsedLinearIssueInput = {
identifier: string
organizationUrlKey?: string
}
const LINEAR_IDENTIFIER_PATTERN = /^[A-Za-z][A-Za-z0-9_]*-\d+$/
export function parseLinearIssueInput(input: string): ParsedLinearIssueInput | null {
const trimmed = input.trim()
if (!trimmed) {
return null
}
if (LINEAR_IDENTIFIER_PATTERN.test(trimmed)) {
return { identifier: trimmed.toUpperCase() }
}
try {
const parsed = new URL(trimmed)
if (parsed.hostname !== 'linear.app') {
return null
}
const parts = parsed.pathname.split('/').filter(Boolean)
const issueIndex = parts.indexOf('issue')
const organizationUrlKey = parts[0]
const rawIdentifier = issueIndex >= 0 ? parts[issueIndex + 1] : undefined
if (!organizationUrlKey || !rawIdentifier) {
return null
}
const identifier = decodeURIComponent(rawIdentifier).split(/[/?#]/)[0]
if (!LINEAR_IDENTIFIER_PATTERN.test(identifier)) {
return null
}
return {
identifier: identifier.toUpperCase(),
organizationUrlKey: decodeURIComponent(organizationUrlKey)
}
} catch {
return null
}
}
+6
View File
@@ -248,6 +248,8 @@ export type Worktree = {
linkedIssue: number | null
linkedPR: number | null
linkedLinearIssue: string | null
linkedLinearIssueWorkspaceId?: string | null
linkedLinearIssueOrganizationUrlKey?: string | null
// Why: parallel slots for GitLab work-item references. Kept as separate
// fields (rather than reusing linkedIssue / linkedPR with a provider
// discriminator) so the persistence layer is unambiguous when a user
@@ -322,6 +324,8 @@ export type WorktreeMeta = {
linkedIssue: number | null
linkedPR: number | null
linkedLinearIssue: string | null
linkedLinearIssueWorkspaceId?: string | null
linkedLinearIssueOrganizationUrlKey?: string | null
/** Optional for backward compatibility — see Worktree.linkedGitLabMR. */
linkedGitLabMR?: number | null
/** Optional for backward compatibility — see Worktree.linkedGitLabIssue. */
@@ -1630,6 +1634,8 @@ export type CreateWorktreeArgs = {
linkedIssue?: number
linkedPR?: number
linkedLinearIssue?: string
linkedLinearIssueWorkspaceId?: string | null
linkedLinearIssueOrganizationUrlKey?: string | null
linkedGitLabIssue?: number
linkedGitLabMR?: number
pushTarget?: GitPushTarget