mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
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:
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user