Files
orca/src/main/linear/linear-issue-comments.ts
T
Brennan Benson e69baa3499 fix(linear): stop writes when their caller lifetime ends (#35020)
* 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
2026-10-29 13:15:07 -07:00

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()
}
}