mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
* fix(linear): bound write preparation and mutation to one caller lifetime * fix(linear): isolate cancellable preparation from shared reads * test(linear): keep CLI lifetime coverage in its compiler project * fix(linear): preserve comment change failure guidance * test(linear): place CLI and runtime coverage with integration tests * test(linear): reset CLI exit status across runtimes * fix(linear): preserve truthful confirmation deadline causes * test(linear): support renamed workspace selector in fixture * test(linear): adapt relation lifetime fixture to client factory
294 lines
8.5 KiB
TypeScript
294 lines
8.5 KiB
TypeScript
import { taskProviderFailure } from '../../shared/task-provider-failure'
|
|
import type { LinearClient } from '@linear/sdk'
|
|
import type { LinearComment } from '../../shared/linear/issue-types'
|
|
import { acquire, release } from './linear-request-concurrency'
|
|
import { clearToken } from './linear-token-store'
|
|
import { getClients, getPublicFileUrlClient, isAuthError } from './client'
|
|
import {
|
|
ATTACHMENT_BY_UUID_QUERY,
|
|
COMMENT_BY_UUID_QUERY,
|
|
ISSUE_COMMENTS_QUERY,
|
|
type LinearAttachmentByUuidResponse,
|
|
type LinearCommentByUuidResponse,
|
|
type LinearIssueCommentsResponse,
|
|
type LinearRawVariables
|
|
} from './linear-issue-query-documents'
|
|
import {
|
|
LinearWriteFailure,
|
|
confirmLinearWrite,
|
|
mapRawAttachmentWriteRecord,
|
|
mapRawCommentWriteRecord,
|
|
runLinearWrite,
|
|
type LinearAttachmentWriteRecord,
|
|
type LinearCommentWriteRecord
|
|
} from './linear-issue-write-support'
|
|
|
|
export async function addIssueComment(
|
|
issueId: string,
|
|
body: string,
|
|
workspaceId?: string | null,
|
|
options?: { id?: string; parentId?: string | null }
|
|
): Promise<
|
|
| { ok: true; id: string; url?: string | null; parentId?: string | null }
|
|
| { ok: false; error: string }
|
|
> {
|
|
const entry = getClients(workspaceId)[0]
|
|
if (!entry) {
|
|
return taskProviderFailure('linear.notConnected', 'Not connected to Linear')
|
|
}
|
|
|
|
await acquire()
|
|
try {
|
|
const result = await entry.client.createComment({
|
|
...(options?.id ? { id: options.id } : {}),
|
|
issueId,
|
|
body,
|
|
...(options?.parentId ? { parentId: options.parentId } : {})
|
|
})
|
|
if (!result.success) {
|
|
return taskProviderFailure('linear.commentFailed', 'Failed to create comment')
|
|
}
|
|
const comment = await result.comment
|
|
return {
|
|
ok: true,
|
|
id: comment?.id ?? '',
|
|
url: comment?.url ?? null,
|
|
parentId: options?.parentId ?? null
|
|
}
|
|
} catch (error) {
|
|
if (isAuthError(error)) {
|
|
clearToken(entry.workspace.id)
|
|
throw error
|
|
}
|
|
const message = error instanceof Error ? error.message : String(error)
|
|
return { ok: false, error: message }
|
|
} finally {
|
|
release()
|
|
}
|
|
}
|
|
|
|
export async function addIssueCommentForAgent(
|
|
issueId: string,
|
|
body: string,
|
|
workspaceId: string,
|
|
options: { id: string; parentId?: string | null; signal?: AbortSignal; beforeInvoke?: () => void }
|
|
): Promise<LinearCommentWriteRecord> {
|
|
const entry = getClients(workspaceId)[0]
|
|
if (!entry) {
|
|
throw new LinearWriteFailure('failed', 'Not connected to Linear')
|
|
}
|
|
|
|
return runLinearWrite(
|
|
entry,
|
|
options.signal,
|
|
async (client) => {
|
|
const result = await client.createComment({
|
|
id: options.id,
|
|
issueId,
|
|
body,
|
|
...(options.parentId ? { parentId: options.parentId } : {})
|
|
})
|
|
if (!result.success) {
|
|
throw new LinearWriteFailure('failed', 'Failed to create comment')
|
|
}
|
|
options.signal?.throwIfAborted()
|
|
const comment = await confirmLinearWrite(
|
|
'Comment was created but could not be retrieved',
|
|
async () => result.comment
|
|
)
|
|
if (!comment?.id) {
|
|
throw new LinearWriteFailure(
|
|
'unconfirmed',
|
|
'Comment was created but could not be retrieved'
|
|
)
|
|
}
|
|
const record = await confirmLinearWrite(
|
|
'Comment was created but could not be retrieved',
|
|
() => readCommentWriteRecord(client, comment.id, options.signal)
|
|
)
|
|
if (!record) {
|
|
throw new LinearWriteFailure(
|
|
'unconfirmed',
|
|
'Comment was created but could not be retrieved'
|
|
)
|
|
}
|
|
return record
|
|
},
|
|
options.beforeInvoke
|
|
)
|
|
}
|
|
|
|
export async function updateIssueCommentForAgent(
|
|
commentId: string,
|
|
body: string,
|
|
workspaceId: string,
|
|
options: { signal?: AbortSignal; beforeInvoke?: () => void } = {}
|
|
): Promise<LinearCommentWriteRecord> {
|
|
const entry = getClients(workspaceId)[0]
|
|
if (!entry) {
|
|
throw new LinearWriteFailure('failed', 'Not connected to Linear')
|
|
}
|
|
|
|
return runLinearWrite(
|
|
entry,
|
|
options.signal,
|
|
async (client) => {
|
|
const result = await client.updateComment(commentId, { body })
|
|
if (!result.success) {
|
|
throw new LinearWriteFailure('failed', 'Failed to edit comment')
|
|
}
|
|
const record = await confirmLinearWrite('Comment was edited but could not be retrieved', () =>
|
|
readCommentWriteRecord(client, commentId, options.signal)
|
|
)
|
|
if (!record) {
|
|
throw new LinearWriteFailure('unconfirmed', 'Comment was edited but could not be retrieved')
|
|
}
|
|
return record
|
|
},
|
|
options.beforeInvoke
|
|
)
|
|
}
|
|
|
|
export async function deleteIssueCommentForAgent(
|
|
commentId: string,
|
|
workspaceId: string,
|
|
options: { signal?: AbortSignal; beforeInvoke?: () => void } = {}
|
|
): Promise<void> {
|
|
const entry = getClients(workspaceId)[0]
|
|
if (!entry) {
|
|
throw new LinearWriteFailure('failed', 'Not connected to Linear')
|
|
}
|
|
|
|
await runLinearWrite(
|
|
entry,
|
|
options.signal,
|
|
async (client) => {
|
|
const result = await client.deleteComment(commentId)
|
|
if (!result.success) {
|
|
throw new LinearWriteFailure('failed', 'Failed to delete comment')
|
|
}
|
|
},
|
|
options.beforeInvoke
|
|
)
|
|
}
|
|
|
|
export async function createIssueAttachment(
|
|
issueId: string,
|
|
input: { id: string; title: string; url: string },
|
|
workspaceId: string,
|
|
options: { signal?: AbortSignal; beforeInvoke?: () => void } = {}
|
|
): Promise<LinearAttachmentWriteRecord> {
|
|
const entry = getClients(workspaceId)[0]
|
|
if (!entry) {
|
|
throw new LinearWriteFailure('failed', 'Not connected to Linear')
|
|
}
|
|
|
|
return runLinearWrite(
|
|
entry,
|
|
options.signal,
|
|
async (client) => {
|
|
const result = await client.createAttachment({
|
|
id: input.id,
|
|
issueId,
|
|
title: input.title,
|
|
url: input.url
|
|
})
|
|
if (!result.success) {
|
|
throw new LinearWriteFailure('failed', 'Failed to create attachment')
|
|
}
|
|
options.signal?.throwIfAborted()
|
|
const attachment = await confirmLinearWrite(
|
|
'Attachment was created but could not be retrieved',
|
|
async () => result.attachment
|
|
)
|
|
if (!attachment?.id) {
|
|
throw new LinearWriteFailure(
|
|
'unconfirmed',
|
|
'Attachment was created but could not be retrieved'
|
|
)
|
|
}
|
|
const record = await confirmLinearWrite(
|
|
'Attachment was created but could not be retrieved',
|
|
() => readAttachmentWriteRecord(client, attachment.id, options.signal)
|
|
)
|
|
if (!record) {
|
|
throw new LinearWriteFailure(
|
|
'unconfirmed',
|
|
'Attachment was created but could not be retrieved'
|
|
)
|
|
}
|
|
return record
|
|
},
|
|
options.beforeInvoke
|
|
)
|
|
}
|
|
|
|
async function readCommentWriteRecord(
|
|
client: LinearClient,
|
|
id: string,
|
|
signal?: AbortSignal
|
|
): Promise<LinearCommentWriteRecord | null> {
|
|
signal?.throwIfAborted()
|
|
const result = await client.client.rawRequest<LinearCommentByUuidResponse, LinearRawVariables>(
|
|
COMMENT_BY_UUID_QUERY,
|
|
{ id }
|
|
)
|
|
const comment = result.data?.comment
|
|
return comment ? mapRawCommentWriteRecord(comment) : null
|
|
}
|
|
|
|
async function readAttachmentWriteRecord(
|
|
client: LinearClient,
|
|
id: string,
|
|
signal?: AbortSignal
|
|
): Promise<LinearAttachmentWriteRecord | null> {
|
|
signal?.throwIfAborted()
|
|
const result = await client.client.rawRequest<LinearAttachmentByUuidResponse, LinearRawVariables>(
|
|
ATTACHMENT_BY_UUID_QUERY,
|
|
{ id }
|
|
)
|
|
const attachment = result.data?.attachment
|
|
return attachment ? mapRawAttachmentWriteRecord(attachment) : null
|
|
}
|
|
|
|
export async function getIssueComments(
|
|
issueId: string,
|
|
workspaceId?: string | null
|
|
): Promise<LinearComment[]> {
|
|
const entry = getClients(workspaceId)[0]
|
|
if (!entry) {
|
|
return []
|
|
}
|
|
|
|
await acquire()
|
|
try {
|
|
const result = await getPublicFileUrlClient(entry).client.rawRequest<
|
|
LinearIssueCommentsResponse,
|
|
LinearRawVariables
|
|
>(ISSUE_COMMENTS_QUERY, { id: issueId })
|
|
const nodes = result.data?.issue?.comments?.nodes ?? []
|
|
const comments = nodes.map((node) => ({
|
|
id: node.id,
|
|
body: node.body ?? '',
|
|
createdAt: node.createdAt ?? '',
|
|
user: node.user
|
|
? {
|
|
displayName: node.user.displayName ?? '',
|
|
avatarUrl: node.user.avatarUrl ?? undefined
|
|
}
|
|
: undefined
|
|
}))
|
|
// Why: Linear sends newest first; oldest-first matches linear.app and where new comments land.
|
|
return comments.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
|
|
} catch (error) {
|
|
if (isAuthError(error)) {
|
|
clearToken(entry.workspace.id)
|
|
throw error
|
|
}
|
|
console.warn('[linear] getIssueComments failed:', error)
|
|
return []
|
|
} finally {
|
|
release()
|
|
}
|
|
}
|