mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
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
This commit is contained in:
@@ -75,12 +75,15 @@ export async function searchLinearIssuesForAgents(args: {
|
||||
|
||||
export async function resolveIssue(
|
||||
identifier: string,
|
||||
selectors: { workspaceId?: string | null; organizationUrlKey?: string | null }
|
||||
selectors: { workspaceId?: string | null; organizationUrlKey?: string | null },
|
||||
signal?: AbortSignal
|
||||
): Promise<ResolvedIssue> {
|
||||
const workspace = resolveWorkspaceSelector(selectors, getConnectedWorkspaces())
|
||||
const selection = workspace?.id ?? selectors.workspaceId ?? 'all'
|
||||
const { entries, failures: entryFailures } =
|
||||
selection === 'all' ? getFanoutClientEntries() : getExplicitClientEntries(selection)
|
||||
selection === 'all'
|
||||
? getFanoutClientEntries(signal)
|
||||
: getExplicitClientEntries(selection, signal)
|
||||
if (entries.length === 0) {
|
||||
throwIfExplicitWorkspaceHasConnectedAlternatives(selection)
|
||||
if (entryFailures[0]) {
|
||||
@@ -91,7 +94,7 @@ export async function resolveIssue(
|
||||
})
|
||||
}
|
||||
|
||||
const results = await readIssueWorkspaces(entries, identifier, selection, entryFailures)
|
||||
const results = await readIssueWorkspaces(entries, identifier, selection, entryFailures, signal)
|
||||
|
||||
if (results.length === 0) {
|
||||
throw linearError('linear_issue_not_found', `Linear issue ${identifier} was not found.`)
|
||||
@@ -118,12 +121,15 @@ export function getRequiredEntry(
|
||||
return entry
|
||||
}
|
||||
|
||||
function getExplicitClientEntries(workspaceId?: string): {
|
||||
function getExplicitClientEntries(
|
||||
workspaceId?: string,
|
||||
signal?: AbortSignal
|
||||
): {
|
||||
entries: LinearClientForWorkspace[]
|
||||
failures: WorkspaceReadFailure[]
|
||||
} {
|
||||
try {
|
||||
return { entries: getClients(workspaceId), failures: [] }
|
||||
return { entries: getClients(workspaceId, signal), failures: [] }
|
||||
} catch (error) {
|
||||
if (error instanceof LinearAgentAccessError) {
|
||||
throw error
|
||||
@@ -161,11 +167,13 @@ function throwIfExplicitWorkspaceHasConnectedAlternatives(
|
||||
export async function withLinearRead<T>(
|
||||
entry: LinearClientForWorkspace,
|
||||
read: () => Promise<T>,
|
||||
selection?: (string & {}) | 'all'
|
||||
selection?: (string & {}) | 'all',
|
||||
signal?: AbortSignal
|
||||
): Promise<T> {
|
||||
void selection
|
||||
await acquire()
|
||||
await acquire(signal)
|
||||
try {
|
||||
signal?.throwIfAborted()
|
||||
return await read()
|
||||
} catch (error) {
|
||||
if (isAuthError(error)) {
|
||||
@@ -182,16 +190,22 @@ export async function withLinearRead<T>(
|
||||
|
||||
async function readIssueWorkspace(
|
||||
entry: LinearClientForWorkspace,
|
||||
identifier: string
|
||||
identifier: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<ResolvedIssue | null> {
|
||||
const response = await withLinearRead(entry, async () => {
|
||||
const client = getPublicFileUrlClient(entry)
|
||||
const raw = await client.client.rawRequest<RawIssueResponse, Record<string, unknown>>(
|
||||
ISSUE_QUERY,
|
||||
{ id: identifier }
|
||||
)
|
||||
return raw.data?.issue ?? null
|
||||
})
|
||||
const response = await withLinearRead(
|
||||
entry,
|
||||
async () => {
|
||||
const client = getPublicFileUrlClient(entry, signal)
|
||||
const raw = await client.client.rawRequest<RawIssueResponse, Record<string, unknown>>(
|
||||
ISSUE_QUERY,
|
||||
{ id: identifier }
|
||||
)
|
||||
return raw.data?.issue ?? null
|
||||
},
|
||||
undefined,
|
||||
signal
|
||||
)
|
||||
return response ? { issue: mapIssue(response), workspace: entry.workspace } : null
|
||||
}
|
||||
|
||||
@@ -199,15 +213,16 @@ async function readIssueWorkspaces(
|
||||
entries: LinearClientForWorkspace[],
|
||||
identifier: string,
|
||||
selection: (string & {}) | 'all',
|
||||
initialFailures: WorkspaceReadFailure[] = []
|
||||
initialFailures: WorkspaceReadFailure[] = [],
|
||||
signal?: AbortSignal
|
||||
): Promise<ResolvedIssue[]> {
|
||||
if (selection !== 'all') {
|
||||
const selected = await readIssueWorkspace(entries[0], identifier)
|
||||
const selected = await readIssueWorkspace(entries[0], identifier, signal)
|
||||
return selected ? [selected] : []
|
||||
}
|
||||
|
||||
const settled = await Promise.allSettled(
|
||||
entries.map((entry) => readIssueWorkspace(entry, identifier))
|
||||
entries.map((entry) => readIssueWorkspace(entry, identifier, signal))
|
||||
)
|
||||
const results: ResolvedIssue[] = []
|
||||
const failures: LinearAgentAccessError[] = initialFailures.map((failure) => failure.error)
|
||||
|
||||
@@ -11,20 +11,20 @@ export type WorkspaceReadFailure = {
|
||||
error: LinearAgentAccessError
|
||||
}
|
||||
|
||||
export function getFanoutClientEntries(): {
|
||||
export function getFanoutClientEntries(signal?: AbortSignal): {
|
||||
entries: LinearClientForWorkspace[]
|
||||
failures: WorkspaceReadFailure[]
|
||||
} {
|
||||
const workspaces = getStatus().workspaces ?? []
|
||||
if (workspaces.length === 0) {
|
||||
return { entries: getClients('all'), failures: [] }
|
||||
return { entries: getClients('all', signal), failures: [] }
|
||||
}
|
||||
|
||||
const entries: LinearClientForWorkspace[] = []
|
||||
const failures: WorkspaceReadFailure[] = []
|
||||
for (const workspace of workspaces) {
|
||||
try {
|
||||
const entry = getClients(workspace.id)[0]
|
||||
const entry = getClients(workspace.id, signal)[0]
|
||||
if (entry) {
|
||||
entries.push(entry)
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ describe('Linear issue context', () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
expect(getClients).toHaveBeenCalledWith('workspace-stably')
|
||||
expect(getClients).toHaveBeenCalledWith('workspace-stably', undefined)
|
||||
})
|
||||
|
||||
it('returns partial context and attachment metadata when the optional comment read fails', async () => {
|
||||
|
||||
@@ -15,8 +15,10 @@ import { readOptionalIncludes } from './includes'
|
||||
|
||||
export async function readLinearIssueContext(
|
||||
request: LinearIssueRequest,
|
||||
resolveCurrent: (context?: LinearCurrentIssueContextHints) => Promise<CurrentIssueLink>
|
||||
resolveCurrent: (context?: LinearCurrentIssueContextHints) => Promise<CurrentIssueLink>,
|
||||
signal?: AbortSignal
|
||||
): Promise<LinearIssueContextResult> {
|
||||
signal?.throwIfAborted()
|
||||
if (request.workspaceId === 'all') {
|
||||
throw linearError('linear_invalid_workspace', '--workspace all is not valid for issue reads.', {
|
||||
nextSteps: ['Pass a concrete Linear workspace id or omit --workspace.']
|
||||
@@ -40,10 +42,14 @@ export async function readLinearIssueContext(
|
||||
throw linearError('linear_issue_required', 'Pass an issue id or use --current.')
|
||||
}
|
||||
|
||||
const resolved = await resolveIssue(identifier, {
|
||||
workspaceId: request.workspaceId ?? currentLink?.workspaceId ?? undefined,
|
||||
organizationUrlKey: parsed?.organizationUrlKey ?? currentLink?.organizationUrlKey
|
||||
})
|
||||
const resolved = await resolveIssue(
|
||||
identifier,
|
||||
{
|
||||
workspaceId: request.workspaceId ?? currentLink?.workspaceId ?? undefined,
|
||||
organizationUrlKey: parsed?.organizationUrlKey ?? currentLink?.organizationUrlKey
|
||||
},
|
||||
signal
|
||||
)
|
||||
return buildIssueContextResult(resolved, request, currentLink)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { acquire, release } from './linear-request-concurrency'
|
||||
import { linearError } from './issue-context/errors'
|
||||
import { runLinearWriteAction } from '../runtime/runtime-linear-write-action'
|
||||
import { writeIssueRelation } from './issue-relation-write'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ rawRequest: vi.fn() }))
|
||||
vi.mock('./client', () => ({
|
||||
getClients: () => [{ workspace: { id: 'w1' }, apiKey: 'inert-fixture' }],
|
||||
isAuthError: () => false
|
||||
}))
|
||||
vi.mock('./linear-sdk', () => ({
|
||||
createLinearClient: () => ({ client: { rawRequest: mocks.rawRequest } })
|
||||
}))
|
||||
|
||||
const issue = { id: 'issue-1', identifier: 'ENG-1', title: 'Title', url: 'https://example.com/1' }
|
||||
const relatedIssue = { ...issue, id: 'issue-2', identifier: 'ENG-2' }
|
||||
const unconfirmed = () =>
|
||||
linearError('linear_write_unconfirmed', 'Read the relations before retrying.')
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
mocks.rawRequest.mockReset()
|
||||
})
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it.each(['add', 'remove'] as const)(
|
||||
'does not %s after a held relation scan loses its caller or deadline',
|
||||
async (operation) => {
|
||||
for (const reason of ['caller', 'deadline']) {
|
||||
const caller = new AbortController()
|
||||
let finish = () => {}
|
||||
mocks.rawRequest.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finish = () =>
|
||||
resolve({
|
||||
data: {
|
||||
issue: {
|
||||
relations: {
|
||||
nodes:
|
||||
operation === 'remove' ? [{ id: 'r1', type: 'blocks', relatedIssue }] : [],
|
||||
pageInfo: { hasNextPage: false }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
)
|
||||
let outcome: unknown
|
||||
const pending = runLinearWriteAction(
|
||||
async () => null,
|
||||
(_owner, options) =>
|
||||
writeIssueRelation({
|
||||
issue,
|
||||
relatedIssue,
|
||||
relationship: 'blocks',
|
||||
operation,
|
||||
workspaceId: 'w1',
|
||||
...options
|
||||
}),
|
||||
unconfirmed,
|
||||
() => linearError('linear_write_failed', 'Failed'),
|
||||
caller.signal
|
||||
).catch((error) => {
|
||||
outcome = error
|
||||
})
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(mocks.rawRequest).toHaveBeenCalledTimes(1)
|
||||
if (reason === 'caller') {
|
||||
caller.abort()
|
||||
} else {
|
||||
await vi.advanceTimersByTimeAsync(25_000)
|
||||
}
|
||||
await pending
|
||||
expect(outcome).toMatchObject({ code: 'linear_timeout' })
|
||||
finish()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(mocks.rawRequest).toHaveBeenCalledTimes(1)
|
||||
mocks.rawRequest.mockClear()
|
||||
await Promise.all([acquire(), acquire(), acquire(), acquire()])
|
||||
for (let i = 0; i < 4; i++) {
|
||||
release()
|
||||
}
|
||||
} finally {
|
||||
finish()
|
||||
await pending
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('removes cancelled relation queue waits without leaking a permit or invoking Linear', async () => {
|
||||
await Promise.all([acquire(), acquire(), acquire(), acquire()])
|
||||
const caller = new AbortController()
|
||||
const pending = writeIssueRelation({
|
||||
issue,
|
||||
relatedIssue,
|
||||
relationship: 'blocks',
|
||||
operation: 'add',
|
||||
workspaceId: 'w1',
|
||||
signal: caller.signal
|
||||
})
|
||||
caller.abort(new Error('disconnected'))
|
||||
try {
|
||||
await expect(pending).rejects.toThrow('disconnected')
|
||||
} finally {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
release()
|
||||
}
|
||||
}
|
||||
expect(mocks.rawRequest).not.toHaveBeenCalled()
|
||||
await Promise.all([acquire(), acquire(), acquire(), acquire()])
|
||||
for (let i = 0; i < 4; i++) {
|
||||
release()
|
||||
}
|
||||
})
|
||||
@@ -29,17 +29,20 @@ export async function writeIssueRelation(params: {
|
||||
operation: 'add' | 'remove'
|
||||
workspaceId: string
|
||||
signal?: AbortSignal
|
||||
beforeInvoke?: () => void
|
||||
}): Promise<LinearIssueRelationWriteResult> {
|
||||
const entry = getClients(params.workspaceId)[0]
|
||||
if (!entry) {
|
||||
throw new LinearWriteFailure('failed', 'Not connected to Linear')
|
||||
}
|
||||
await acquire()
|
||||
await acquire(params.signal)
|
||||
try {
|
||||
params.signal?.throwIfAborted()
|
||||
const client = params.signal
|
||||
? createLinearClient({ apiKey: entry.apiKey, signal: params.signal })
|
||||
: entry.client
|
||||
const existing = await findExistingRelation(client, params)
|
||||
params.signal?.throwIfAborted()
|
||||
if (params.operation === 'add' && existing) {
|
||||
return result(params, existing, true)
|
||||
}
|
||||
@@ -47,9 +50,13 @@ export async function writeIssueRelation(params: {
|
||||
return result(params, absentRelation(params), true)
|
||||
}
|
||||
if (params.operation === 'remove' && existing) {
|
||||
params.beforeInvoke?.()
|
||||
params.signal?.throwIfAborted()
|
||||
await deleteLinearIssueRelation(client, existing.id)
|
||||
return result(params, existing, false)
|
||||
}
|
||||
params.beforeInvoke?.()
|
||||
params.signal?.throwIfAborted()
|
||||
const created = await createLinearIssueRelation(client, relationCreateInput(params))
|
||||
return result(params, normalizeRelation(created, params.issue.id), false)
|
||||
} catch (error) {
|
||||
@@ -68,9 +75,11 @@ async function findExistingRelation(
|
||||
issue: { id: string; identifier: string }
|
||||
relatedIssue: { id: string }
|
||||
relationship: LinearIssueRelationship
|
||||
signal?: AbortSignal
|
||||
}
|
||||
): Promise<LinearIssueRelationWriteResult['relation'] | null> {
|
||||
for (const direction of relationDirections(params.relationship)) {
|
||||
params.signal?.throwIfAborted()
|
||||
const scan = await findRelationInDirection(client, params, direction)
|
||||
if (scan.relation) {
|
||||
return scan.relation
|
||||
@@ -97,6 +106,7 @@ async function findRelationInDirection(
|
||||
issue: { id: string }
|
||||
relatedIssue: { id: string }
|
||||
relationship: LinearIssueRelationship
|
||||
signal?: AbortSignal
|
||||
},
|
||||
direction: RelationDirection
|
||||
): Promise<{ relation: LinearIssueRelationWriteResult['relation'] | null; hasMore: boolean }> {
|
||||
@@ -104,6 +114,7 @@ async function findRelationInDirection(
|
||||
let inspected = 0
|
||||
|
||||
while (inspected < RELATION_WRITE_READ_CAP) {
|
||||
params.signal?.throwIfAborted()
|
||||
const first = Math.min(LINEAR_ISSUE_API_PAGE_SIZE_MAX, RELATION_WRITE_READ_CAP - inspected)
|
||||
const raw = await client.client.rawRequest<RawRelationsResponse, Record<string, unknown>>(
|
||||
direction === 'outbound' ? RELATIONS_QUERY : INVERSE_RELATIONS_QUERY,
|
||||
|
||||
@@ -71,38 +71,51 @@ export async function addIssueCommentForAgent(
|
||||
issueId: string,
|
||||
body: string,
|
||||
workspaceId: string,
|
||||
options: { id: string; parentId?: string | null; signal?: AbortSignal }
|
||||
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')
|
||||
}
|
||||
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)
|
||||
)
|
||||
if (!record) {
|
||||
throw new LinearWriteFailure('unconfirmed', 'Comment was created but could not be retrieved')
|
||||
}
|
||||
return record
|
||||
})
|
||||
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(
|
||||
@@ -125,7 +138,7 @@ export async function updateIssueCommentForAgent(
|
||||
throw new LinearWriteFailure('failed', 'Failed to edit comment')
|
||||
}
|
||||
const record = await confirmLinearWrite('Comment was edited but could not be retrieved', () =>
|
||||
readCommentWriteRecord(client, commentId)
|
||||
readCommentWriteRecord(client, commentId, options.signal)
|
||||
)
|
||||
if (!record) {
|
||||
throw new LinearWriteFailure('unconfirmed', 'Comment was edited but could not be retrieved')
|
||||
@@ -163,51 +176,59 @@ export async function createIssueAttachment(
|
||||
issueId: string,
|
||||
input: { id: string; title: string; url: string },
|
||||
workspaceId: string,
|
||||
options: { signal?: AbortSignal } = {}
|
||||
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')
|
||||
}
|
||||
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'
|
||||
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
|
||||
)
|
||||
}
|
||||
const record = await confirmLinearWrite(
|
||||
'Attachment was created but could not be retrieved',
|
||||
() => readAttachmentWriteRecord(client, attachment.id)
|
||||
)
|
||||
if (!record) {
|
||||
throw new LinearWriteFailure(
|
||||
'unconfirmed',
|
||||
'Attachment was created but could not be retrieved'
|
||||
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)
|
||||
)
|
||||
}
|
||||
return record
|
||||
})
|
||||
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
|
||||
id: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<LinearCommentWriteRecord | null> {
|
||||
signal?.throwIfAborted()
|
||||
const result = await client.client.rawRequest<LinearCommentByUuidResponse, LinearRawVariables>(
|
||||
COMMENT_BY_UUID_QUERY,
|
||||
{ id }
|
||||
@@ -218,8 +239,10 @@ async function readCommentWriteRecord(
|
||||
|
||||
async function readAttachmentWriteRecord(
|
||||
client: LinearClient,
|
||||
id: string
|
||||
id: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<LinearAttachmentWriteRecord | null> {
|
||||
signal?.throwIfAborted()
|
||||
const result = await client.client.rawRequest<LinearAttachmentByUuidResponse, LinearRawVariables>(
|
||||
ATTACHMENT_BY_UUID_QUERY,
|
||||
{ id }
|
||||
|
||||
@@ -95,21 +95,26 @@ async function readIssueWithSignal(
|
||||
|
||||
export async function getIssueByUuidForAgent(
|
||||
id: string,
|
||||
workspaceId?: string | null
|
||||
workspaceId?: string | null,
|
||||
signal?: AbortSignal
|
||||
): Promise<LinearIssueWriteRecord | null> {
|
||||
const entry = getClients(workspaceId)[0]
|
||||
const entry = getClients(workspaceId, signal)[0]
|
||||
if (!entry) {
|
||||
return null
|
||||
}
|
||||
|
||||
return runLinearLookup(entry, async () => {
|
||||
const result = await entry.client.client.rawRequest<
|
||||
LinearIssueByUuidResponse,
|
||||
LinearRawVariables
|
||||
>(ISSUE_BY_UUID_QUERY, { id })
|
||||
const issue = result.data?.issue ?? null
|
||||
return issue ? mapRawIssueWriteRecord(issue) : null
|
||||
})
|
||||
return runLinearLookup(
|
||||
entry,
|
||||
async () => {
|
||||
const result = await entry.client.client.rawRequest<
|
||||
LinearIssueByUuidResponse,
|
||||
LinearRawVariables
|
||||
>(ISSUE_BY_UUID_QUERY, { id })
|
||||
const issue = result.data?.issue ?? null
|
||||
return issue ? mapRawIssueWriteRecord(issue) : null
|
||||
},
|
||||
signal
|
||||
)
|
||||
}
|
||||
|
||||
export async function getCommentByUuidForAgent(
|
||||
@@ -139,29 +144,35 @@ export async function getCommentByUuidForAgent(
|
||||
|
||||
export async function getAttachmentByUuidForAgent(
|
||||
id: string,
|
||||
workspaceId?: string | null
|
||||
workspaceId?: string | null,
|
||||
signal?: AbortSignal
|
||||
): Promise<LinearAttachmentWriteRecord | null> {
|
||||
const entry = getClients(workspaceId)[0]
|
||||
const entry = getClients(workspaceId, signal)[0]
|
||||
if (!entry) {
|
||||
return null
|
||||
}
|
||||
|
||||
return runLinearLookup(entry, async () => {
|
||||
const result = await entry.client.client.rawRequest<
|
||||
LinearAttachmentByUuidResponse,
|
||||
LinearRawVariables
|
||||
>(ATTACHMENT_BY_UUID_QUERY, { id })
|
||||
const attachment = result.data?.attachment
|
||||
return attachment ? mapRawAttachmentWriteRecord(attachment) : null
|
||||
})
|
||||
return runLinearLookup(
|
||||
entry,
|
||||
async () => {
|
||||
const result = await entry.client.client.rawRequest<
|
||||
LinearAttachmentByUuidResponse,
|
||||
LinearRawVariables
|
||||
>(ATTACHMENT_BY_UUID_QUERY, { id })
|
||||
const attachment = result.data?.attachment
|
||||
return attachment ? mapRawAttachmentWriteRecord(attachment) : null
|
||||
},
|
||||
signal
|
||||
)
|
||||
}
|
||||
|
||||
export async function getIssueCommentThreadRoot(
|
||||
issueId: string,
|
||||
commentId: string,
|
||||
workspaceId?: string | null
|
||||
workspaceId?: string | null,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ id: string; parentId: string | null } | null> {
|
||||
const comment = await getCommentByUuidForAgent(commentId, workspaceId)
|
||||
const comment = await getCommentByUuidForAgent(commentId, workspaceId, signal)
|
||||
if (!comment || comment.issue.id !== issueId) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -103,6 +103,7 @@ export async function createIssueForAgent(
|
||||
dueDate?: string | null
|
||||
labelIds?: string[]
|
||||
signal?: AbortSignal
|
||||
beforeInvoke?: () => void
|
||||
}
|
||||
): Promise<LinearIssueWriteRecord> {
|
||||
const entry = getClients(workspaceId)[0]
|
||||
@@ -110,41 +111,49 @@ export async function createIssueForAgent(
|
||||
throw new LinearWriteFailure('failed', 'Not connected to Linear')
|
||||
}
|
||||
|
||||
return runLinearWrite(entry, options.signal, async (client) => {
|
||||
const result = await client.createIssue({
|
||||
id: options.id,
|
||||
teamId,
|
||||
title,
|
||||
...(description ? { description } : {}),
|
||||
...(options.parentId ? { parentId: options.parentId } : {}),
|
||||
...(options.projectId ? { projectId: options.projectId } : {}),
|
||||
...(options.stateId ? { stateId: options.stateId } : {}),
|
||||
...(options.assigneeId !== undefined ? { assigneeId: options.assigneeId } : {}),
|
||||
...(options.priority !== undefined ? { priority: options.priority } : {}),
|
||||
...(options.estimate !== undefined ? { estimate: options.estimate } : {}),
|
||||
...(options.dueDate !== undefined ? { dueDate: options.dueDate } : {}),
|
||||
...(options.labelIds !== undefined ? { labelIds: options.labelIds } : {})
|
||||
})
|
||||
if (!result.success) {
|
||||
throw new LinearWriteFailure('failed', 'Linear create failed')
|
||||
}
|
||||
const issue = await confirmLinearWrite(
|
||||
'Issue was created but could not be retrieved',
|
||||
async () => result.issue
|
||||
)
|
||||
if (!issue?.id) {
|
||||
throw new LinearWriteFailure('unconfirmed', 'Issue was created but could not be retrieved')
|
||||
}
|
||||
return confirmLinearWrite('Issue was created but could not be retrieved', () =>
|
||||
getCreatedIssueRecord(issue.id, client)
|
||||
)
|
||||
})
|
||||
return runLinearWrite(
|
||||
entry,
|
||||
options.signal,
|
||||
async (client) => {
|
||||
const result = await client.createIssue({
|
||||
id: options.id,
|
||||
teamId,
|
||||
title,
|
||||
...(description ? { description } : {}),
|
||||
...(options.parentId ? { parentId: options.parentId } : {}),
|
||||
...(options.projectId ? { projectId: options.projectId } : {}),
|
||||
...(options.stateId ? { stateId: options.stateId } : {}),
|
||||
...(options.assigneeId !== undefined ? { assigneeId: options.assigneeId } : {}),
|
||||
...(options.priority !== undefined ? { priority: options.priority } : {}),
|
||||
...(options.estimate !== undefined ? { estimate: options.estimate } : {}),
|
||||
...(options.dueDate !== undefined ? { dueDate: options.dueDate } : {}),
|
||||
...(options.labelIds !== undefined ? { labelIds: options.labelIds } : {})
|
||||
})
|
||||
if (!result.success) {
|
||||
throw new LinearWriteFailure('failed', 'Linear create failed')
|
||||
}
|
||||
options.signal?.throwIfAborted()
|
||||
const issue = await confirmLinearWrite(
|
||||
'Issue was created but could not be retrieved',
|
||||
async () => result.issue
|
||||
)
|
||||
if (!issue?.id) {
|
||||
throw new LinearWriteFailure('unconfirmed', 'Issue was created but could not be retrieved')
|
||||
}
|
||||
return confirmLinearWrite('Issue was created but could not be retrieved', () =>
|
||||
getCreatedIssueRecord(issue.id, client, options.signal)
|
||||
)
|
||||
},
|
||||
options.beforeInvoke
|
||||
)
|
||||
}
|
||||
|
||||
async function getCreatedIssueRecord(
|
||||
issueId: string,
|
||||
client: LinearClient
|
||||
client: LinearClient,
|
||||
signal?: AbortSignal
|
||||
): Promise<LinearIssueWriteRecord> {
|
||||
signal?.throwIfAborted()
|
||||
const result = await client.client.rawRequest<LinearIssueByUuidResponse, LinearRawVariables>(
|
||||
ISSUE_BY_UUID_QUERY,
|
||||
{ id: issueId }
|
||||
@@ -224,51 +233,56 @@ export async function updateIssueForAgent(
|
||||
id: string,
|
||||
updates: LinearIssueUpdate,
|
||||
workspaceId: string,
|
||||
options: { signal?: AbortSignal } = {}
|
||||
options: { signal?: AbortSignal; beforeInvoke?: () => void } = {}
|
||||
): Promise<LinearIssueWriteRecord> {
|
||||
const entry = getClients(workspaceId)[0]
|
||||
if (!entry) {
|
||||
throw new LinearWriteFailure('failed', 'Not connected to Linear')
|
||||
}
|
||||
|
||||
return runLinearWrite(entry, options.signal, async (client) => {
|
||||
const payload: Record<string, unknown> = {}
|
||||
if (updates.stateId !== undefined) {
|
||||
payload.stateId = updates.stateId
|
||||
}
|
||||
if (updates.title !== undefined) {
|
||||
payload.title = updates.title
|
||||
}
|
||||
if (updates.description !== undefined) {
|
||||
payload.description = updates.description
|
||||
}
|
||||
if (updates.assigneeId !== undefined) {
|
||||
payload.assigneeId = updates.assigneeId
|
||||
}
|
||||
if (updates.priority !== undefined) {
|
||||
payload.priority = updates.priority
|
||||
}
|
||||
if (updates.estimate !== undefined) {
|
||||
payload.estimate = updates.estimate
|
||||
}
|
||||
if (updates.dueDate !== undefined) {
|
||||
payload.dueDate = updates.dueDate
|
||||
}
|
||||
if (updates.labelIds !== undefined) {
|
||||
payload.labelIds = updates.labelIds
|
||||
}
|
||||
if (updates.projectId !== undefined) {
|
||||
payload.projectId = updates.projectId
|
||||
}
|
||||
if (updates.parentId !== undefined) {
|
||||
payload.parentId = updates.parentId
|
||||
}
|
||||
const result = await client.updateIssue(id, payload)
|
||||
if (!result.success) {
|
||||
throw new LinearWriteFailure('failed', 'Linear update failed')
|
||||
}
|
||||
return confirmLinearWrite('Issue was updated but could not be retrieved', () =>
|
||||
getCreatedIssueRecord(id, client)
|
||||
)
|
||||
})
|
||||
return runLinearWrite(
|
||||
entry,
|
||||
options.signal,
|
||||
async (client) => {
|
||||
const payload: Record<string, unknown> = {}
|
||||
if (updates.stateId !== undefined) {
|
||||
payload.stateId = updates.stateId
|
||||
}
|
||||
if (updates.title !== undefined) {
|
||||
payload.title = updates.title
|
||||
}
|
||||
if (updates.description !== undefined) {
|
||||
payload.description = updates.description
|
||||
}
|
||||
if (updates.assigneeId !== undefined) {
|
||||
payload.assigneeId = updates.assigneeId
|
||||
}
|
||||
if (updates.priority !== undefined) {
|
||||
payload.priority = updates.priority
|
||||
}
|
||||
if (updates.estimate !== undefined) {
|
||||
payload.estimate = updates.estimate
|
||||
}
|
||||
if (updates.dueDate !== undefined) {
|
||||
payload.dueDate = updates.dueDate
|
||||
}
|
||||
if (updates.labelIds !== undefined) {
|
||||
payload.labelIds = updates.labelIds
|
||||
}
|
||||
if (updates.projectId !== undefined) {
|
||||
payload.projectId = updates.projectId
|
||||
}
|
||||
if (updates.parentId !== undefined) {
|
||||
payload.parentId = updates.parentId
|
||||
}
|
||||
const result = await client.updateIssue(id, payload)
|
||||
if (!result.success) {
|
||||
throw new LinearWriteFailure('failed', 'Linear update failed')
|
||||
}
|
||||
return confirmLinearWrite('Issue was updated but could not be retrieved', () =>
|
||||
getCreatedIssueRecord(id, client, options.signal)
|
||||
)
|
||||
},
|
||||
options.beforeInvoke
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { setImmediate } from 'node:timers/promises'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { getProject, listProjects, listProjectsByExactName } from './linear-project-queries'
|
||||
import { listProjectTeams } from './linear-project-team-queries'
|
||||
import { acquire, release } from './linear-request-concurrency'
|
||||
|
||||
const { provider } = vi.hoisted(() => ({ provider: vi.fn() }))
|
||||
vi.mock('./client', () => ({
|
||||
getClients: (_workspaceId: string, signal?: AbortSignal) => [
|
||||
{
|
||||
workspace: { id: 'workspace-1', organizationName: 'Workspace' },
|
||||
client: { client: { rawRequest: () => provider(signal) } }
|
||||
}
|
||||
],
|
||||
isAuthError: () => false
|
||||
}))
|
||||
vi.mock('./linear-token-store', () => ({ clearToken: vi.fn() }))
|
||||
|
||||
const project = { id: 'project-1', name: 'Project' }
|
||||
const teams = [{ id: 'team-1', name: 'Team', key: 'TM' }]
|
||||
const connection = { nodes: [project], pageInfo: { hasNextPage: false } }
|
||||
const response = {
|
||||
data: {
|
||||
project: { ...project, teams: { nodes: teams, pageInfo: { hasNextPage: false } } },
|
||||
projects: connection,
|
||||
searchProjects: connection
|
||||
}
|
||||
}
|
||||
const readers = [
|
||||
{
|
||||
name: 'collection',
|
||||
read: (force = false, signal?: AbortSignal) =>
|
||||
listProjects(undefined, 20, 'workspace-1', force, signal),
|
||||
expected: { items: [project] }
|
||||
},
|
||||
{
|
||||
name: 'exact name',
|
||||
read: (force = false, signal?: AbortSignal) =>
|
||||
listProjectsByExactName('Project', 'workspace-1', force, signal),
|
||||
expected: [project]
|
||||
},
|
||||
{
|
||||
name: 'detail',
|
||||
read: (force = false, signal?: AbortSignal) =>
|
||||
getProject('project-1', 'workspace-1', force, signal),
|
||||
expected: project
|
||||
},
|
||||
{
|
||||
name: 'teams',
|
||||
read: (force = false, signal?: AbortSignal) =>
|
||||
listProjectTeams('project-1', 'workspace-1', force, signal),
|
||||
expected: teams
|
||||
}
|
||||
]
|
||||
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it.each(readers)(
|
||||
'$name keeps cancellation request-owned and preserves shared refreshes',
|
||||
async ({ read, expected }) => {
|
||||
for (const signalledFirst of [true, false]) {
|
||||
const pending: { signal?: AbortSignal; finish: () => void }[] = []
|
||||
const observedAbort = vi.fn()
|
||||
provider.mockImplementation(
|
||||
(signal?: AbortSignal) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const onAbort = () => {
|
||||
observedAbort()
|
||||
reject(signal?.reason)
|
||||
}
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
pending.push({
|
||||
signal,
|
||||
finish: () => {
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
resolve(response)
|
||||
}
|
||||
})
|
||||
})
|
||||
)
|
||||
const caller = new AbortController()
|
||||
const cancelled = new Error('write caller disconnected')
|
||||
const first = read(signalledFirst, signalledFirst ? caller.signal : undefined)
|
||||
const second = read(false, signalledFirst ? undefined : caller.signal)
|
||||
const shared = read()
|
||||
const signalled = signalledFirst ? first : second
|
||||
const independent = signalledFirst ? second : first
|
||||
const outcome = signalled.catch((error: unknown) => error)
|
||||
const settled = Promise.allSettled([first, second, shared])
|
||||
try {
|
||||
await setImmediate()
|
||||
expect(pending).toHaveLength(2)
|
||||
expect(pending.filter((request) => request.signal === caller.signal)).toHaveLength(1)
|
||||
caller.abort(cancelled)
|
||||
await expect(outcome).resolves.toBe(cancelled)
|
||||
expect(observedAbort).toHaveBeenCalledTimes(1)
|
||||
|
||||
// A forced shared refresh replaces the shared read, never the request-owned read.
|
||||
const refreshed = read(true)
|
||||
const joinedRefresh = read()
|
||||
const refreshedSettled = Promise.allSettled([refreshed, joinedRefresh])
|
||||
try {
|
||||
await setImmediate()
|
||||
expect(pending).toHaveLength(3)
|
||||
pending[2].finish()
|
||||
await expect(refreshed).resolves.toMatchObject(expected)
|
||||
await expect(joinedRefresh).resolves.toMatchObject(expected)
|
||||
for (const request of pending) {
|
||||
request.finish()
|
||||
}
|
||||
await expect(independent).resolves.toMatchObject(expected)
|
||||
await expect(shared).resolves.toMatchObject(expected)
|
||||
} finally {
|
||||
for (const request of pending) {
|
||||
request.finish()
|
||||
}
|
||||
await refreshedSettled
|
||||
}
|
||||
} finally {
|
||||
caller.abort(cancelled)
|
||||
for (const request of pending) {
|
||||
request.finish()
|
||||
}
|
||||
await settled
|
||||
}
|
||||
|
||||
// All four real queue permits remain available after both owners settle.
|
||||
await Promise.all([acquire(), acquire(), acquire(), acquire()])
|
||||
for (let i = 0; i < 4; i++) {
|
||||
release()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -17,20 +17,22 @@ export async function readCollection<T>(
|
||||
key: string,
|
||||
workspaceId: LinearWorkspaceSelection | null | undefined,
|
||||
load: (entry: LinearClientForWorkspace) => Promise<LinearCollectionResult<T>>,
|
||||
force = false
|
||||
force = false,
|
||||
signal?: AbortSignal
|
||||
): Promise<LinearCollectionResult<T>> {
|
||||
return coalesce(
|
||||
key,
|
||||
async () => {
|
||||
const entries = getClients(workspaceId)
|
||||
const entries = getClients(workspaceId, signal)
|
||||
if (entries.length === 0) {
|
||||
return { items: [] }
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
await acquire()
|
||||
await acquire(signal)
|
||||
try {
|
||||
signal?.throwIfAborted()
|
||||
return await load(entry)
|
||||
} catch (error) {
|
||||
if (isAuthError(error)) {
|
||||
@@ -56,7 +58,8 @@ export async function readCollection<T>(
|
||||
hasMore: results.some((result) => result.hasMore)
|
||||
}
|
||||
},
|
||||
force
|
||||
force,
|
||||
signal
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,16 @@ export function clampLimit(limit = 20): number {
|
||||
return Math.min(Math.max(1, Math.floor(limit)), LINEAR_PROJECT_API_PAGE_SIZE_MAX)
|
||||
}
|
||||
|
||||
export function coalesce<T>(key: string, load: () => Promise<T>, force = false): Promise<T> {
|
||||
export function coalesce<T>(
|
||||
key: string,
|
||||
load: () => Promise<T>,
|
||||
force = false,
|
||||
signal?: AbortSignal
|
||||
): Promise<T> {
|
||||
// Request-owned cancellation must neither join nor replace independent readers' work.
|
||||
if (signal !== undefined) {
|
||||
return load()
|
||||
}
|
||||
const existing = inFlight.get(key) as Promise<T> | undefined
|
||||
if (existing && !force) {
|
||||
return existing
|
||||
|
||||
@@ -34,7 +34,8 @@ export async function listProjects(
|
||||
query: string | undefined,
|
||||
limit = 20,
|
||||
workspaceId?: LinearWorkspaceSelection | null,
|
||||
force = false
|
||||
force = false,
|
||||
signal?: AbortSignal
|
||||
): Promise<LinearCollectionResult<LinearProjectSummary>> {
|
||||
const first = clampLimit(limit)
|
||||
const trimmed = query?.trim()
|
||||
@@ -54,14 +55,16 @@ export async function listProjects(
|
||||
hasMore: !!connection?.pageInfo?.hasNextPage
|
||||
}
|
||||
},
|
||||
force
|
||||
force,
|
||||
signal
|
||||
)
|
||||
}
|
||||
|
||||
export async function listProjectsByExactName(
|
||||
name: string,
|
||||
workspaceId: LinearConcreteWorkspaceId,
|
||||
force = false
|
||||
force = false,
|
||||
signal?: AbortSignal
|
||||
): Promise<LinearProjectSummary[]> {
|
||||
const projectName = name.trim()
|
||||
if (!projectName) {
|
||||
@@ -73,16 +76,17 @@ export async function listProjectsByExactName(
|
||||
return coalesce(
|
||||
key,
|
||||
async () => {
|
||||
const entries = getClients(concreteWorkspaceId)
|
||||
const entries = getClients(concreteWorkspaceId, signal)
|
||||
const entry = entries[0]
|
||||
if (!entry) {
|
||||
return []
|
||||
}
|
||||
await acquire()
|
||||
await acquire(signal)
|
||||
try {
|
||||
const matches: LinearProjectSummary[] = []
|
||||
let after: string | undefined
|
||||
while (true) {
|
||||
signal?.throwIfAborted()
|
||||
const result = await entry.client.client.rawRequest<
|
||||
ProjectConnectionResponse,
|
||||
LinearRawVariables
|
||||
@@ -118,14 +122,16 @@ export async function listProjectsByExactName(
|
||||
release()
|
||||
}
|
||||
},
|
||||
force
|
||||
force,
|
||||
signal
|
||||
)
|
||||
}
|
||||
|
||||
export async function getProject(
|
||||
id: string,
|
||||
workspaceId: LinearConcreteWorkspaceId,
|
||||
force = false
|
||||
force = false,
|
||||
signal?: AbortSignal
|
||||
): Promise<LinearProjectDetail | null> {
|
||||
const projectId = id.trim()
|
||||
const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId)
|
||||
@@ -136,13 +142,14 @@ export async function getProject(
|
||||
return coalesce(
|
||||
key,
|
||||
async () => {
|
||||
const entries = getClients(concreteWorkspaceId)
|
||||
const entries = getClients(concreteWorkspaceId, signal)
|
||||
const entry = entries[0]
|
||||
if (!entry) {
|
||||
return null
|
||||
}
|
||||
await acquire()
|
||||
await acquire(signal)
|
||||
try {
|
||||
signal?.throwIfAborted()
|
||||
const result = await entry.client.client.rawRequest<
|
||||
ProjectConnectionResponse,
|
||||
LinearRawVariables
|
||||
@@ -159,7 +166,8 @@ export async function getProject(
|
||||
release()
|
||||
}
|
||||
},
|
||||
force
|
||||
force,
|
||||
signal
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@ import { coalesce, normalizeConcreteWorkspaceId } from './linear-project-models'
|
||||
export async function listProjectTeams(
|
||||
projectId: string,
|
||||
workspaceId: LinearConcreteWorkspaceId,
|
||||
force = false
|
||||
force = false,
|
||||
signal?: AbortSignal
|
||||
): Promise<NonNullable<LinearProjectSummary['teams']>> {
|
||||
const id = projectId.trim()
|
||||
if (!id) {
|
||||
@@ -21,15 +22,16 @@ export async function listProjectTeams(
|
||||
return coalesce(
|
||||
key,
|
||||
async () => {
|
||||
const entry = getClients(concreteWorkspaceId)[0]
|
||||
const entry = getClients(concreteWorkspaceId, signal)[0]
|
||||
if (!entry) {
|
||||
return []
|
||||
}
|
||||
const teams: NonNullable<LinearProjectSummary['teams']> = []
|
||||
let after: string | undefined
|
||||
await acquire()
|
||||
await acquire(signal)
|
||||
try {
|
||||
while (true) {
|
||||
signal?.throwIfAborted()
|
||||
const result = await entry.client.client.rawRequest<
|
||||
ProjectTeamsResponse,
|
||||
LinearRawVariables
|
||||
@@ -72,6 +74,7 @@ export async function listProjectTeams(
|
||||
release()
|
||||
}
|
||||
},
|
||||
force
|
||||
force,
|
||||
signal
|
||||
)
|
||||
}
|
||||
|
||||
@@ -39,10 +39,13 @@ type TeamStateNode = {
|
||||
}
|
||||
|
||||
export async function fetchAllTeamsForWorkspace(
|
||||
entry: LinearClientForWorkspace
|
||||
entry: LinearClientForWorkspace,
|
||||
signal?: AbortSignal
|
||||
): Promise<LinearTeam[]> {
|
||||
signal?.throwIfAborted()
|
||||
let page = await entry.client.teams({ first: TEAM_PAGE_SIZE })
|
||||
while (page.pageInfo.hasNextPage) {
|
||||
signal?.throwIfAborted()
|
||||
await page.fetchNext()
|
||||
}
|
||||
return page.nodes.map((t) => ({
|
||||
@@ -59,11 +62,15 @@ export async function fetchAllTeamsForWorkspace(
|
||||
}))
|
||||
}
|
||||
|
||||
export async function fetchAllTeamStates(team: {
|
||||
states: (variables?: { first?: number }) => Promise<LinearConnectionPage<TeamStateNode>>
|
||||
}): Promise<LinearWorkflowState[]> {
|
||||
export async function fetchAllTeamStates(
|
||||
team: {
|
||||
states: (variables?: { first?: number }) => Promise<LinearConnectionPage<TeamStateNode>>
|
||||
},
|
||||
signal?: AbortSignal
|
||||
): Promise<LinearWorkflowState[]> {
|
||||
const states = await team.states({ first: TEAM_PAGE_SIZE })
|
||||
while (states.pageInfo.hasNextPage) {
|
||||
signal?.throwIfAborted()
|
||||
await states.fetchNext()
|
||||
}
|
||||
return states.nodes
|
||||
@@ -77,11 +84,15 @@ export async function fetchAllTeamStates(team: {
|
||||
.sort((a, b) => a.position - b.position)
|
||||
}
|
||||
|
||||
export async function fetchAllTeamLabels(team: {
|
||||
labels: (variables?: { first?: number }) => Promise<LinearConnectionPage<TeamLabelNode>>
|
||||
}): Promise<LinearLabel[]> {
|
||||
export async function fetchAllTeamLabels(
|
||||
team: {
|
||||
labels: (variables?: { first?: number }) => Promise<LinearConnectionPage<TeamLabelNode>>
|
||||
},
|
||||
signal?: AbortSignal
|
||||
): Promise<LinearLabel[]> {
|
||||
const labels = await team.labels({ first: TEAM_PAGE_SIZE })
|
||||
while (labels.pageInfo.hasNextPage) {
|
||||
signal?.throwIfAborted()
|
||||
await labels.fetchNext()
|
||||
}
|
||||
return labels.nodes
|
||||
@@ -89,11 +100,15 @@ export async function fetchAllTeamLabels(team: {
|
||||
.map((l) => ({ id: l.id, name: l.name, color: l.color }))
|
||||
}
|
||||
|
||||
export async function fetchAllTeamMembers(team: {
|
||||
members: (variables?: { first?: number }) => Promise<LinearConnectionPage<TeamMemberNode>>
|
||||
}): Promise<LinearMember[]> {
|
||||
export async function fetchAllTeamMembers(
|
||||
team: {
|
||||
members: (variables?: { first?: number }) => Promise<LinearConnectionPage<TeamMemberNode>>
|
||||
},
|
||||
signal?: AbortSignal
|
||||
): Promise<LinearMember[]> {
|
||||
const members = await team.members({ first: TEAM_PAGE_SIZE })
|
||||
while (members.pageInfo.hasNextPage) {
|
||||
signal?.throwIfAborted()
|
||||
await members.fetchNext()
|
||||
}
|
||||
return members.nodes.map((m) => ({
|
||||
|
||||
@@ -248,7 +248,7 @@ describe('Linear teams', () => {
|
||||
{ id: 'label-3', name: 'Backend', color: '#ff0000' }
|
||||
])
|
||||
expect(connection.fetchNext).toHaveBeenCalledTimes(3)
|
||||
expect(getClients).toHaveBeenCalledWith('workspace-1')
|
||||
expect(getClients).toHaveBeenCalledWith('workspace-1', undefined)
|
||||
expect(entry.client.team).toHaveBeenCalledWith('team-1')
|
||||
expect(labels).toHaveBeenCalledWith({ first: 100 })
|
||||
}
|
||||
|
||||
+28
-18
@@ -49,18 +49,19 @@ export async function listTeams(
|
||||
}
|
||||
|
||||
export async function listTeamsOrThrow(
|
||||
workspaceId?: LinearWorkspaceSelection | null
|
||||
workspaceId?: LinearWorkspaceSelection | null,
|
||||
signal?: AbortSignal
|
||||
): Promise<LinearTeam[]> {
|
||||
const entries = getClients(workspaceId)
|
||||
const entries = getClients(workspaceId, signal)
|
||||
if (entries.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
await acquire()
|
||||
await acquire(signal)
|
||||
try {
|
||||
return await fetchAllTeamsForWorkspace(entry)
|
||||
return await fetchAllTeamsForWorkspace(entry, signal)
|
||||
} catch (error) {
|
||||
if (isAuthError(error)) {
|
||||
clearToken(entry.workspace.id)
|
||||
@@ -114,17 +115,21 @@ export async function listTeamsForAgent(
|
||||
async function readTeamResource<T>(
|
||||
teamId: string,
|
||||
workspaceId: string | null | undefined,
|
||||
fetchAll: (team: Team) => Promise<T[]>,
|
||||
fallbackWarning?: string
|
||||
fetchAll: (team: Team, signal?: AbortSignal) => Promise<T[]>,
|
||||
fallbackWarning?: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<T[]> {
|
||||
const entry = getClients(workspaceId)[0]
|
||||
const entry = getClients(workspaceId, signal)[0]
|
||||
if (!entry) {
|
||||
return []
|
||||
}
|
||||
|
||||
await acquire()
|
||||
await acquire(signal)
|
||||
try {
|
||||
return await fetchAll(await entry.client.team(teamId))
|
||||
signal?.throwIfAborted()
|
||||
const team = await entry.client.team(teamId)
|
||||
signal?.throwIfAborted()
|
||||
return await fetchAll(team, signal)
|
||||
} catch (error) {
|
||||
if (isAuthError(error)) {
|
||||
clearToken(entry.workspace.id)
|
||||
@@ -149,9 +154,10 @@ export async function getTeamStates(
|
||||
|
||||
export async function getTeamStatesOrThrow(
|
||||
teamId: string,
|
||||
workspaceId?: string | null
|
||||
workspaceId?: string | null,
|
||||
signal?: AbortSignal
|
||||
): Promise<LinearWorkflowState[]> {
|
||||
return readTeamResource(teamId, workspaceId, fetchAllTeamStates)
|
||||
return readTeamResource(teamId, workspaceId, fetchAllTeamStates, undefined, signal)
|
||||
}
|
||||
|
||||
export async function getTeamLabels(
|
||||
@@ -163,9 +169,10 @@ export async function getTeamLabels(
|
||||
|
||||
export async function getTeamLabelsOrThrow(
|
||||
teamId: string,
|
||||
workspaceId?: string | null
|
||||
workspaceId?: string | null,
|
||||
signal?: AbortSignal
|
||||
): Promise<LinearLabel[]> {
|
||||
return readTeamResource(teamId, workspaceId, fetchAllTeamLabels)
|
||||
return readTeamResource(teamId, workspaceId, fetchAllTeamLabels, undefined, signal)
|
||||
}
|
||||
|
||||
export async function getTeamMembers(
|
||||
@@ -177,21 +184,24 @@ export async function getTeamMembers(
|
||||
|
||||
export async function getTeamMembersOrThrow(
|
||||
teamId: string,
|
||||
workspaceId?: string | null
|
||||
workspaceId?: string | null,
|
||||
signal?: AbortSignal
|
||||
): Promise<LinearMember[]> {
|
||||
return readTeamResource(teamId, workspaceId, fetchAllTeamMembers)
|
||||
return readTeamResource(teamId, workspaceId, fetchAllTeamMembers, undefined, signal)
|
||||
}
|
||||
|
||||
export async function getViewerForWorkspaceOrThrow(
|
||||
workspaceId: string
|
||||
workspaceId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ id: string; displayName?: string | null; avatarUrl?: string | null }> {
|
||||
const entry = getClients(workspaceId)[0]
|
||||
const entry = getClients(workspaceId, signal)[0]
|
||||
if (!entry) {
|
||||
throw new Error('Not connected to Linear')
|
||||
}
|
||||
|
||||
await acquire()
|
||||
await acquire(signal)
|
||||
try {
|
||||
signal?.throwIfAborted()
|
||||
const viewer = await entry.client.viewer
|
||||
return {
|
||||
id: viewer.id,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { LINEAR_WRITE_BODY_CAP } from '../../shared/linear/agent-access'
|
||||
import * as mutations from '../linear/linear-issue-mutations'
|
||||
import * as linearTeams from '../linear/teams'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
|
||||
@@ -43,8 +44,12 @@ describe('Linear save issue', () => {
|
||||
meta: { workspaceId: 'workspace-1', writeId: 'write-1', deduplicated: false }
|
||||
})
|
||||
|
||||
const signal = new AbortController().signal
|
||||
await expect(
|
||||
runtime.linearSaveIssue({ team: 'ENG', title: 'New issue', workspaceId: 'workspace-1' })
|
||||
runtime.linearSaveIssue(
|
||||
{ team: 'ENG', title: 'New issue', workspaceId: 'workspace-1' },
|
||||
signal
|
||||
)
|
||||
).resolves.toMatchObject({ meta: { created: true } })
|
||||
|
||||
expect(create).toHaveBeenCalledWith(
|
||||
@@ -52,7 +57,8 @@ describe('Linear save issue', () => {
|
||||
teamInput: 'ENG',
|
||||
title: 'New issue',
|
||||
workspaceId: 'workspace-1'
|
||||
})
|
||||
}),
|
||||
signal
|
||||
)
|
||||
})
|
||||
|
||||
@@ -83,7 +89,7 @@ describe('Linear save issue', () => {
|
||||
|
||||
it('does not send a mutation or confirmation read when every field is already set', async () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const runWrite = vi.fn()
|
||||
const mutation = vi.spyOn(mutations, 'updateIssueForAgent')
|
||||
const notify = vi.fn().mockResolvedValue(undefined)
|
||||
Object.assign(runtime, {
|
||||
resolveLinearAgentWriteTarget: vi
|
||||
@@ -91,7 +97,6 @@ describe('Linear save issue', () => {
|
||||
.mockResolvedValue({ issue, workspaceId: 'workspace-1' }),
|
||||
readLinearAgentIssueWriteRecord: vi.fn().mockResolvedValue(issue),
|
||||
buildLinearSaveUpdate: vi.fn().mockResolvedValue({ title: issue.title }),
|
||||
runLinearAgentWrite: runWrite,
|
||||
notifyLinearLinkedIssueUpdated: notify
|
||||
})
|
||||
|
||||
@@ -99,7 +104,8 @@ describe('Linear save issue', () => {
|
||||
runtime.linearSaveIssue({ input: issue.identifier, title: issue.title })
|
||||
).resolves.toMatchObject({ issue, meta: { created: false } })
|
||||
|
||||
expect(runWrite).not.toHaveBeenCalled()
|
||||
expect(mutation).not.toHaveBeenCalled()
|
||||
expect(runtime.readLinearAgentIssueWriteRecord).toHaveBeenCalledTimes(1)
|
||||
expect(notify).toHaveBeenCalledWith('workspace-1', issue.identifier)
|
||||
})
|
||||
|
||||
|
||||
@@ -5,7 +5,11 @@ import { RpcDispatcher } from '../dispatcher'
|
||||
import type { RpcRequest } from '../core'
|
||||
import { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { LinearWriteFailure } from '../../../linear/linear-issue-write-support'
|
||||
import { sanitizeLinearErrorMessage } from '../../../linear/issue-context/errors'
|
||||
import {
|
||||
linearError,
|
||||
sanitizeLinearErrorMessage,
|
||||
type LinearAgentAccessError
|
||||
} from '../../../linear/issue-context/errors'
|
||||
import { LINEAR_AGENT_ACCESS_METHODS } from './linear-agent-access'
|
||||
|
||||
function makeRequest(method: string, params?: unknown): RpcRequest {
|
||||
@@ -59,6 +63,7 @@ describe('Linear agent access RPC methods', () => {
|
||||
linearSaveIssue: vi.fn().mockResolvedValue({ ok: true }),
|
||||
linearIssueCreate: vi.fn().mockResolvedValue({ ok: true })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const signal = new AbortController().signal
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: LINEAR_AGENT_ACCESS_METHODS })
|
||||
|
||||
const setStateResponse = await dispatcher.dispatch(
|
||||
@@ -66,7 +71,8 @@ describe('Linear agent access RPC methods', () => {
|
||||
input: 'ENG-1',
|
||||
to: 'In Review',
|
||||
workspaceId: 'workspace-1'
|
||||
})
|
||||
}),
|
||||
{ signal }
|
||||
)
|
||||
const teamListResponse = await dispatcher.dispatch(
|
||||
makeRequest('linear.agentTeamList', { workspaceId: 'all' })
|
||||
@@ -86,14 +92,16 @@ describe('Linear agent access RPC methods', () => {
|
||||
teamInput: 'ENG',
|
||||
limit: 10,
|
||||
workspaceId: 'workspace-1'
|
||||
})
|
||||
}),
|
||||
{ signal }
|
||||
)
|
||||
const projectListResponse = await dispatcher.dispatch(
|
||||
makeRequest('linear.agentProjectList', {
|
||||
query: 'launch',
|
||||
limit: 10,
|
||||
workspaceId: 'all'
|
||||
})
|
||||
}),
|
||||
{ signal }
|
||||
)
|
||||
const taskUpdateResponse = await dispatcher.dispatch(
|
||||
makeRequest('linear.issueUpdateTask', {
|
||||
@@ -101,7 +109,8 @@ describe('Linear agent access RPC methods', () => {
|
||||
operation: 'dueDate',
|
||||
dueDate: '2026-06-30',
|
||||
workspaceId: 'workspace-1'
|
||||
})
|
||||
}),
|
||||
{ signal }
|
||||
)
|
||||
const relationResponse = await dispatcher.dispatch(
|
||||
makeRequest('linear.issueRelationWrite', {
|
||||
@@ -110,7 +119,8 @@ describe('Linear agent access RPC methods', () => {
|
||||
relationship: 'blockedBy',
|
||||
operation: 'add',
|
||||
workspaceId: 'workspace-1'
|
||||
})
|
||||
}),
|
||||
{ signal }
|
||||
)
|
||||
const commentResponse = await dispatcher.dispatch(
|
||||
makeRequest('linear.issueAddComment', {
|
||||
@@ -119,7 +129,8 @@ describe('Linear agent access RPC methods', () => {
|
||||
replyTo: 'comment-1',
|
||||
writeId: '11111111-1111-4111-8111-111111111111',
|
||||
workspaceId: 'workspace-1'
|
||||
})
|
||||
}),
|
||||
{ signal }
|
||||
)
|
||||
const attachResponse = await dispatcher.dispatch(
|
||||
makeRequest('linear.issueAttachLink', {
|
||||
@@ -128,7 +139,8 @@ describe('Linear agent access RPC methods', () => {
|
||||
title: 'Review',
|
||||
writeId: '22222222-2222-4222-8222-222222222222',
|
||||
workspaceId: 'workspace-1'
|
||||
})
|
||||
}),
|
||||
{ signal }
|
||||
)
|
||||
const createResponse = await dispatcher.dispatch(
|
||||
makeRequest('linear.issueCreate', {
|
||||
@@ -140,7 +152,8 @@ describe('Linear agent access RPC methods', () => {
|
||||
parentInput: 'ENG-1',
|
||||
writeId: '33333333-3333-4333-8333-333333333333',
|
||||
workspaceId: 'workspace-1'
|
||||
})
|
||||
}),
|
||||
{ signal }
|
||||
)
|
||||
const saveResponse = await dispatcher.dispatch(
|
||||
makeRequest('linear.saveIssue', {
|
||||
@@ -150,7 +163,8 @@ describe('Linear agent access RPC methods', () => {
|
||||
labels: ['Bug'],
|
||||
project: null,
|
||||
workspaceId: 'workspace-1'
|
||||
})
|
||||
}),
|
||||
{ signal }
|
||||
)
|
||||
|
||||
expect(setStateResponse.ok).toBe(true)
|
||||
@@ -166,11 +180,14 @@ describe('Linear agent access RPC methods', () => {
|
||||
expect(attachResponse.ok).toBe(true)
|
||||
expect(createResponse.ok).toBe(true)
|
||||
expect(saveResponse.ok).toBe(true)
|
||||
expect(runtime.linearIssueSetState).toHaveBeenCalledWith({
|
||||
input: 'ENG-1',
|
||||
to: 'In Review',
|
||||
workspaceId: 'workspace-1'
|
||||
})
|
||||
expect(runtime.linearIssueSetState).toHaveBeenCalledWith(
|
||||
{
|
||||
input: 'ENG-1',
|
||||
to: 'In Review',
|
||||
workspaceId: 'workspace-1'
|
||||
},
|
||||
signal
|
||||
)
|
||||
expect(runtime.linearTeamListForAgents).toHaveBeenCalledWith({ workspaceId: 'all' })
|
||||
expect(runtime.linearTeamMembersForAgents).toHaveBeenCalledWith({
|
||||
teamInput: 'ENG',
|
||||
@@ -195,52 +212,70 @@ describe('Linear agent access RPC methods', () => {
|
||||
limit: 10,
|
||||
workspaceId: 'all'
|
||||
})
|
||||
expect(runtime.linearIssueUpdateTask).toHaveBeenCalledWith({
|
||||
input: 'ENG-1',
|
||||
operation: 'dueDate',
|
||||
dueDate: '2026-06-30',
|
||||
workspaceId: 'workspace-1'
|
||||
})
|
||||
expect(runtime.linearIssueRelationWrite).toHaveBeenCalledWith({
|
||||
input: 'ENG-1',
|
||||
relatedInput: 'ENG-2',
|
||||
relationship: 'blockedBy',
|
||||
operation: 'add',
|
||||
workspaceId: 'workspace-1'
|
||||
})
|
||||
expect(runtime.linearIssueAddComment).toHaveBeenCalledWith({
|
||||
input: 'ENG-1',
|
||||
body: 'Done',
|
||||
replyTo: 'comment-1',
|
||||
writeId: '11111111-1111-4111-8111-111111111111',
|
||||
workspaceId: 'workspace-1'
|
||||
})
|
||||
expect(runtime.linearIssueAttachLink).toHaveBeenCalledWith({
|
||||
input: 'ENG-1',
|
||||
url: 'https://example.com/review/1',
|
||||
title: 'Review',
|
||||
writeId: '22222222-2222-4222-8222-222222222222',
|
||||
workspaceId: 'workspace-1'
|
||||
})
|
||||
expect(runtime.linearIssueCreate).toHaveBeenCalledWith({
|
||||
title: 'Follow up',
|
||||
body: 'Details',
|
||||
teamInput: 'ENG',
|
||||
projectInput: 'project-1',
|
||||
priority: 2,
|
||||
parentInput: 'ENG-1',
|
||||
writeId: '33333333-3333-4333-8333-333333333333',
|
||||
workspaceId: 'workspace-1'
|
||||
})
|
||||
expect(runtime.linearSaveIssue).toHaveBeenCalledWith({
|
||||
input: 'ENG-1',
|
||||
title: 'Updated title',
|
||||
assignee: null,
|
||||
labels: ['Bug'],
|
||||
project: null,
|
||||
workspaceId: 'workspace-1',
|
||||
writeId: undefined
|
||||
})
|
||||
expect(runtime.linearIssueUpdateTask).toHaveBeenCalledWith(
|
||||
{
|
||||
input: 'ENG-1',
|
||||
operation: 'dueDate',
|
||||
dueDate: '2026-06-30',
|
||||
workspaceId: 'workspace-1'
|
||||
},
|
||||
signal
|
||||
)
|
||||
expect(runtime.linearIssueRelationWrite).toHaveBeenCalledWith(
|
||||
{
|
||||
input: 'ENG-1',
|
||||
relatedInput: 'ENG-2',
|
||||
relationship: 'blockedBy',
|
||||
operation: 'add',
|
||||
workspaceId: 'workspace-1'
|
||||
},
|
||||
signal
|
||||
)
|
||||
expect(runtime.linearIssueAddComment).toHaveBeenCalledWith(
|
||||
{
|
||||
input: 'ENG-1',
|
||||
body: 'Done',
|
||||
replyTo: 'comment-1',
|
||||
writeId: '11111111-1111-4111-8111-111111111111',
|
||||
workspaceId: 'workspace-1'
|
||||
},
|
||||
signal
|
||||
)
|
||||
expect(runtime.linearIssueAttachLink).toHaveBeenCalledWith(
|
||||
{
|
||||
input: 'ENG-1',
|
||||
url: 'https://example.com/review/1',
|
||||
title: 'Review',
|
||||
writeId: '22222222-2222-4222-8222-222222222222',
|
||||
workspaceId: 'workspace-1'
|
||||
},
|
||||
signal
|
||||
)
|
||||
expect(runtime.linearIssueCreate).toHaveBeenCalledWith(
|
||||
{
|
||||
title: 'Follow up',
|
||||
body: 'Details',
|
||||
teamInput: 'ENG',
|
||||
projectInput: 'project-1',
|
||||
priority: 2,
|
||||
parentInput: 'ENG-1',
|
||||
writeId: '33333333-3333-4333-8333-333333333333',
|
||||
workspaceId: 'workspace-1'
|
||||
},
|
||||
signal
|
||||
)
|
||||
expect(runtime.linearSaveIssue).toHaveBeenCalledWith(
|
||||
{
|
||||
input: 'ENG-1',
|
||||
title: 'Updated title',
|
||||
assignee: null,
|
||||
labels: ['Bug'],
|
||||
project: null,
|
||||
workspaceId: 'workspace-1',
|
||||
writeId: undefined
|
||||
},
|
||||
signal
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects malformed write ids before the runtime is called', async () => {
|
||||
@@ -343,11 +378,21 @@ describe('Linear agent access RPC methods', () => {
|
||||
})
|
||||
})
|
||||
|
||||
type LinearWriteRunner = {
|
||||
runLinearAgentWrite<T>(
|
||||
write: (signal: AbortSignal) => Promise<T>,
|
||||
unconfirmed: (cause?: string) => Error
|
||||
): Promise<T>
|
||||
function runWrite(
|
||||
runtime: OrcaRuntimeService,
|
||||
write: (signal: AbortSignal) => Promise<unknown>,
|
||||
unconfirmed: (cause?: string) => LinearAgentAccessError
|
||||
): Promise<unknown> {
|
||||
return runtime
|
||||
.runLinearAgentAction(
|
||||
async () => null,
|
||||
async (_owner, options) => {
|
||||
options.beforeInvoke()
|
||||
return write(options.signal)
|
||||
},
|
||||
(_owner, cause) => unconfirmed(cause)
|
||||
)
|
||||
.then(({ result }) => result)
|
||||
}
|
||||
|
||||
type LinearUnconfirmedBuilder = {
|
||||
@@ -356,14 +401,11 @@ type LinearUnconfirmedBuilder = {
|
||||
writeId: string,
|
||||
target: unknown,
|
||||
extra?: unknown
|
||||
): Error & { data?: { cause?: string; nextSteps?: string[] } }
|
||||
): LinearAgentAccessError & { data?: { cause?: string; nextSteps?: string[] } }
|
||||
resolveLinearAgentState(input: string, states: unknown[]): unknown
|
||||
linearCreatedIssueMatchesIntent(issue: unknown, intent: unknown): boolean
|
||||
linearSavedIssueMatchesIntent(issue: unknown, intent: unknown): Promise<boolean>
|
||||
notifyLinearLinkedIssueUpdated(
|
||||
workspaceId: string,
|
||||
identifier: string | readonly string[]
|
||||
): Promise<void>
|
||||
notifyLinearLinkedIssueUpdated(workspaceId: string, identifier: string | readonly string[]): void
|
||||
listResolvedWorktrees(): Promise<unknown[]>
|
||||
}
|
||||
|
||||
@@ -402,17 +444,17 @@ type LinearRetryLookupTester = {
|
||||
describe('Linear agent write recovery helpers', () => {
|
||||
it('keeps stable write failure codes while preserving the Linear provider message', async () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const runner = runtime as unknown as LinearWriteRunner
|
||||
|
||||
await expect(
|
||||
runner.runLinearAgentWrite(
|
||||
runWrite(
|
||||
runtime,
|
||||
async () => {
|
||||
throw new LinearWriteFailure(
|
||||
'failed',
|
||||
'Linear rejected the state transition because the issue is archived.'
|
||||
)
|
||||
},
|
||||
() => Object.assign(new Error('should not be used'), { code: 'linear_write_unconfirmed' })
|
||||
() => linearError('linear_write_unconfirmed', 'should not be used')
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
code: 'linear_write_failed',
|
||||
@@ -422,7 +464,6 @@ describe('Linear agent write recovery helpers', () => {
|
||||
|
||||
it('keeps pinned retry guidance for unconfirmed writes while adding sanitized cause text', async () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const runner = runtime as unknown as LinearWriteRunner
|
||||
const builder = runtime as unknown as LinearUnconfirmedBuilder
|
||||
const writeId = '11111111-1111-4111-8111-111111111111'
|
||||
const target = {
|
||||
@@ -431,7 +472,8 @@ describe('Linear agent write recovery helpers', () => {
|
||||
}
|
||||
|
||||
await expect(
|
||||
runner.runLinearAgentWrite(
|
||||
runWrite(
|
||||
runtime,
|
||||
async () => {
|
||||
throw new LinearWriteFailure(
|
||||
'unconfirmed',
|
||||
@@ -472,14 +514,9 @@ describe('Linear agent write recovery helpers', () => {
|
||||
try {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const write = vi.fn((_signal: AbortSignal) => new Promise<string>(() => undefined))
|
||||
const unconfirmed = vi.fn(() =>
|
||||
Object.assign(new Error('unconfirmed'), { code: 'linear_write_unconfirmed' })
|
||||
)
|
||||
const unconfirmed = vi.fn(() => linearError('linear_write_unconfirmed', 'unconfirmed'))
|
||||
|
||||
const pending = (runtime as unknown as LinearWriteRunner).runLinearAgentWrite(
|
||||
write,
|
||||
unconfirmed
|
||||
)
|
||||
const pending = runWrite(runtime, write, unconfirmed)
|
||||
const rejection = expect(pending).rejects.toMatchObject({
|
||||
code: 'linear_write_unconfirmed'
|
||||
})
|
||||
|
||||
@@ -35,8 +35,8 @@ export const LINEAR_AGENT_ACCESS_METHODS = [
|
||||
name: 'linear.saveIssue',
|
||||
permission: 'workspace',
|
||||
params: LinearSaveIssue,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.linearSaveIssue({ ...params, writeId: parseLinearWriteId(params.writeId) })
|
||||
handler: async (params, { runtime, signal }) =>
|
||||
runtime.linearSaveIssue({ ...params, writeId: parseLinearWriteId(params.writeId) }, signal)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'linear.agentSearchIssues',
|
||||
@@ -101,26 +101,29 @@ export const LINEAR_AGENT_ACCESS_METHODS = [
|
||||
name: 'linear.issueSetState',
|
||||
permission: 'workspace',
|
||||
params: LinearIssueSetState,
|
||||
handler: async (params, { runtime }) => runtime.linearIssueSetState(params)
|
||||
handler: async (params, { runtime, signal }) => runtime.linearIssueSetState(params, signal)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'linear.issueUpdateTask',
|
||||
permission: 'workspace',
|
||||
params: LinearIssueUpdateTask,
|
||||
handler: async (params, { runtime }) => runtime.linearIssueUpdateTask(params)
|
||||
handler: async (params, { runtime, signal }) => runtime.linearIssueUpdateTask(params, signal)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'linear.issueRelationWrite',
|
||||
permission: 'workspace',
|
||||
params: LinearIssueRelationWrite,
|
||||
handler: async (params, { runtime }) => runtime.linearIssueRelationWrite(params)
|
||||
handler: async (params, { runtime, signal }) => runtime.linearIssueRelationWrite(params, signal)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'linear.issueAddComment',
|
||||
permission: 'workspace',
|
||||
params: LinearIssueAddComment,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.linearIssueAddComment({ ...params, writeId: parseLinearWriteId(params.writeId) })
|
||||
handler: async (params, { runtime, signal }) =>
|
||||
runtime.linearIssueAddComment(
|
||||
{ ...params, writeId: parseLinearWriteId(params.writeId) },
|
||||
signal
|
||||
)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'linear.commentEdit',
|
||||
@@ -138,14 +141,17 @@ export const LINEAR_AGENT_ACCESS_METHODS = [
|
||||
name: 'linear.issueAttachLink',
|
||||
permission: 'workspace',
|
||||
params: LinearIssueAttachLink,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.linearIssueAttachLink({ ...params, writeId: parseLinearWriteId(params.writeId) })
|
||||
handler: async (params, { runtime, signal }) =>
|
||||
runtime.linearIssueAttachLink(
|
||||
{ ...params, writeId: parseLinearWriteId(params.writeId) },
|
||||
signal
|
||||
)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'linear.issueCreate',
|
||||
permission: 'workspace',
|
||||
params: LinearIssueCreate,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.linearIssueCreate({ ...params, writeId: parseLinearWriteId(params.writeId) })
|
||||
handler: async (params, { runtime, signal }) =>
|
||||
runtime.linearIssueCreate({ ...params, writeId: parseLinearWriteId(params.writeId) }, signal)
|
||||
})
|
||||
]
|
||||
|
||||
@@ -49,7 +49,11 @@ describe('Linear agent project access helpers', () => {
|
||||
workspaceId: 'workspace-1'
|
||||
})
|
||||
).resolves.toMatchObject({ id: '11111111-1111-4111-8111-111111111111' })
|
||||
expect(readById).toHaveBeenCalledWith('11111111-1111-4111-8111-111111111111', 'workspace-1')
|
||||
expect(readById).toHaveBeenCalledWith(
|
||||
'11111111-1111-4111-8111-111111111111',
|
||||
'workspace-1',
|
||||
undefined
|
||||
)
|
||||
expect(readByName).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -77,8 +81,8 @@ describe('Linear agent project access helpers', () => {
|
||||
await expect(
|
||||
tester.resolveLinearCreateProject(' launch ', { id: 'team-1', workspaceId: 'workspace-1' })
|
||||
).resolves.toMatchObject({ id: 'project-1' })
|
||||
expect(readByName).toHaveBeenCalledWith('launch', 'workspace-1')
|
||||
expect(readExactName).toHaveBeenCalledWith('launch', 'workspace-1')
|
||||
expect(readByName).toHaveBeenCalledWith('launch', 'workspace-1', undefined)
|
||||
expect(readExactName).toHaveBeenCalledWith('launch', 'workspace-1', undefined)
|
||||
})
|
||||
|
||||
it('resolves Linear projects by URL slug without a paginated exact-name scan', async () => {
|
||||
|
||||
@@ -6,9 +6,13 @@ export { resolve } from 'node:path'
|
||||
export type { RuntimeClientEvent } from '../../shared/runtime-client-events'
|
||||
export type {
|
||||
LinearCurrentIssueContextHints,
|
||||
LinearAttachRequest,
|
||||
LinearAttachResult,
|
||||
LinearCommentAddRequest,
|
||||
LinearCommentAddResult,
|
||||
LinearCommentDeleteRequest,
|
||||
LinearCommentDeleteResult,
|
||||
LinearCommentEditRequest,
|
||||
LinearCommentEditResult,
|
||||
LinearCreateResult,
|
||||
LinearErrorCode,
|
||||
|
||||
@@ -9,10 +9,12 @@ import {
|
||||
updateLinearIssueCommentForAgent,
|
||||
LinearWriteFailure
|
||||
} from './runtime-linear-command-dependencies'
|
||||
import { linearError } from '../linear/issue-context/errors'
|
||||
import type { LinearAgentAccessError } from '../linear/issue-context/errors'
|
||||
import { linearError, type LinearAgentAccessError } from '../linear/issue-context/errors'
|
||||
import type {
|
||||
LinearCurrentIssueContextHints,
|
||||
LinearCommentAddRequest,
|
||||
LinearAttachRequest,
|
||||
LinearCommentEditRequest,
|
||||
LinearCommentDeleteRequest,
|
||||
LinearAttachResult,
|
||||
LinearCommentAddResult,
|
||||
LinearCommentDeleteResult,
|
||||
@@ -21,147 +23,170 @@ import type {
|
||||
import { RuntimeLinearCreateCommands } from './runtime-linear-create-commands'
|
||||
import { getConnectedWorkspaces } from '../linear/issue-context/client'
|
||||
import { resolveWorkspaceSelector } from '../linear/issue-context/workspaces'
|
||||
import { runLinearCommentAction } from './runtime-linear-comment-action'
|
||||
|
||||
export class RuntimeLinearCommentCommands extends RuntimeLinearCreateCommands {
|
||||
async linearIssueAddComment(params: {
|
||||
input?: string
|
||||
current?: boolean
|
||||
workspaceId?: string
|
||||
body: string
|
||||
replyTo?: string
|
||||
writeId?: string
|
||||
context?: LinearCurrentIssueContextHints
|
||||
}): Promise<LinearCommentAddResult> {
|
||||
async linearIssueAddComment(
|
||||
params: LinearCommentAddRequest,
|
||||
cancellation?: AbortSignal
|
||||
): Promise<LinearCommentAddResult> {
|
||||
if (params.body.length > LINEAR_WRITE_BODY_CAP) {
|
||||
throw linearError('linear_body_too_large', 'Linear comment body is too large.')
|
||||
}
|
||||
const target = await this.resolveLinearAgentWriteTarget(params)
|
||||
const parentId = params.replyTo
|
||||
? await this.resolveLinearCommentParentId(target.issue.id, params.replyTo, target.workspaceId)
|
||||
: null
|
||||
const writeId = params.writeId ?? randomUUID()
|
||||
const existing =
|
||||
params.writeId !== undefined
|
||||
? await this.getMatchingLinearCommentWrite(
|
||||
writeId,
|
||||
target.issue.id,
|
||||
parentId,
|
||||
target.workspaceId,
|
||||
true
|
||||
)
|
||||
: null
|
||||
if (existing) {
|
||||
await this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier)
|
||||
return this.linearCommentResult(existing, target, params.body.length, writeId, true)
|
||||
}
|
||||
const {
|
||||
owner: { target },
|
||||
result
|
||||
} = await this.runLinearAgentAction(
|
||||
async (signal) => {
|
||||
const target = await this.resolveLinearAgentWriteTarget(params, signal)
|
||||
const parentId = params.replyTo
|
||||
? await this.resolveLinearCommentParentId(
|
||||
target.issue.id,
|
||||
params.replyTo,
|
||||
target.workspaceId,
|
||||
signal
|
||||
)
|
||||
: null
|
||||
const existing =
|
||||
params.writeId !== undefined
|
||||
? await this.getMatchingLinearCommentWrite(
|
||||
writeId,
|
||||
target.issue.id,
|
||||
parentId,
|
||||
target.workspaceId,
|
||||
true,
|
||||
signal
|
||||
)
|
||||
: null
|
||||
return { target, parentId, existing }
|
||||
},
|
||||
async ({ target, parentId, existing }, options) => {
|
||||
if (existing) {
|
||||
return { record: existing, deduplicated: true }
|
||||
}
|
||||
|
||||
try {
|
||||
const comment = await this.runLinearAgentWrite(
|
||||
(signal) =>
|
||||
addLinearIssueCommentForAgent(target.issue.id, params.body, target.workspaceId, {
|
||||
id: writeId,
|
||||
parentId,
|
||||
signal
|
||||
}),
|
||||
(cause) =>
|
||||
this.linearCreateStyleUnconfirmed('comment', writeId, target, {
|
||||
parentId,
|
||||
bodyRequired: true,
|
||||
cause
|
||||
})
|
||||
)
|
||||
await this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier)
|
||||
return this.linearCommentResult(comment, target, params.body.length, writeId, false)
|
||||
} catch (error) {
|
||||
if (error instanceof LinearWriteFailure && error.kind === 'duplicate_id') {
|
||||
const comment = await this.refetchLinearCommentAfterDuplicate(
|
||||
writeId,
|
||||
target.issue.id,
|
||||
parentId,
|
||||
target.workspaceId,
|
||||
() =>
|
||||
this.linearCreateStyleUnconfirmed('comment', writeId, target, {
|
||||
try {
|
||||
const comment = await addLinearIssueCommentForAgent(
|
||||
target.issue.id,
|
||||
params.body,
|
||||
target.workspaceId,
|
||||
{
|
||||
id: writeId,
|
||||
parentId,
|
||||
bodyRequired: true
|
||||
})
|
||||
)
|
||||
await this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier)
|
||||
return this.linearCommentResult(comment, target, params.body.length, writeId, true)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
...options
|
||||
}
|
||||
)
|
||||
|
||||
return { record: comment, deduplicated: false }
|
||||
} catch (error) {
|
||||
if (error instanceof LinearWriteFailure && error.kind === 'duplicate_id') {
|
||||
const comment = await this.refetchLinearCommentAfterDuplicate(
|
||||
writeId,
|
||||
target.issue.id,
|
||||
parentId,
|
||||
target.workspaceId,
|
||||
() =>
|
||||
this.linearCreateStyleUnconfirmed('comment', writeId, target, {
|
||||
parentId,
|
||||
bodyRequired: true
|
||||
}),
|
||||
options.signal
|
||||
)
|
||||
|
||||
return { record: comment, deduplicated: true }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
({ target, parentId }, cause) =>
|
||||
this.linearCreateStyleUnconfirmed('comment', writeId, target, {
|
||||
parentId,
|
||||
bodyRequired: true,
|
||||
cause
|
||||
}),
|
||||
cancellation
|
||||
)
|
||||
void this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier)
|
||||
return this.linearCommentResult(
|
||||
result.record,
|
||||
target,
|
||||
params.body.length,
|
||||
writeId,
|
||||
result.deduplicated
|
||||
)
|
||||
}
|
||||
|
||||
async linearIssueAttachLink(params: {
|
||||
input?: string
|
||||
current?: boolean
|
||||
workspaceId?: string
|
||||
url: string
|
||||
title?: string
|
||||
writeId?: string
|
||||
context?: LinearCurrentIssueContextHints
|
||||
}): Promise<LinearAttachResult> {
|
||||
async linearIssueAttachLink(
|
||||
params: LinearAttachRequest,
|
||||
cancellation?: AbortSignal
|
||||
): Promise<LinearAttachResult> {
|
||||
const url = this.parseLinearAttachmentUrl(params.url)
|
||||
const target = await this.resolveLinearAgentWriteTarget(params)
|
||||
const writeId = params.writeId ?? randomUUID()
|
||||
const title = params.title?.trim() || this.defaultLinearAttachmentTitle(url)
|
||||
const existing =
|
||||
params.writeId !== undefined
|
||||
? await this.getMatchingLinearAttachmentWrite(
|
||||
writeId,
|
||||
target.issue.id,
|
||||
target.workspaceId,
|
||||
true
|
||||
)
|
||||
: null
|
||||
if (existing) {
|
||||
await this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier)
|
||||
return this.linearAttachResult(existing, target, writeId, true)
|
||||
}
|
||||
try {
|
||||
const attachment = await this.runLinearAgentWrite(
|
||||
(signal) =>
|
||||
createLinearIssueAttachment(
|
||||
const {
|
||||
owner: { target },
|
||||
result
|
||||
} = await this.runLinearAgentAction(
|
||||
async (signal) => {
|
||||
const target = await this.resolveLinearAgentWriteTarget(params, signal)
|
||||
const existing =
|
||||
params.writeId !== undefined
|
||||
? await this.getMatchingLinearAttachmentWrite(
|
||||
writeId,
|
||||
target.issue.id,
|
||||
target.workspaceId,
|
||||
true,
|
||||
signal
|
||||
)
|
||||
: null
|
||||
return { target, existing }
|
||||
},
|
||||
async ({ target, existing }, options) => {
|
||||
if (existing) {
|
||||
return { record: existing, deduplicated: true }
|
||||
}
|
||||
try {
|
||||
const attachment = await createLinearIssueAttachment(
|
||||
target.issue.id,
|
||||
{ id: writeId, title, url: url.toString() },
|
||||
target.workspaceId,
|
||||
{ signal }
|
||||
),
|
||||
(cause) =>
|
||||
this.linearCreateStyleUnconfirmed('attach', writeId, target, {
|
||||
title,
|
||||
url: url.toString(),
|
||||
cause
|
||||
})
|
||||
)
|
||||
await this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier)
|
||||
return this.linearAttachResult(attachment, target, writeId, false)
|
||||
} catch (error) {
|
||||
if (error instanceof LinearWriteFailure && error.kind === 'duplicate_id') {
|
||||
const attachment = await this.refetchLinearAttachmentAfterDuplicate(
|
||||
writeId,
|
||||
target.issue.id,
|
||||
target.workspaceId,
|
||||
() =>
|
||||
this.linearCreateStyleUnconfirmed('attach', writeId, target, {
|
||||
title,
|
||||
url: url.toString()
|
||||
})
|
||||
)
|
||||
await this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier)
|
||||
return this.linearAttachResult(attachment, target, writeId, true)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
options
|
||||
)
|
||||
|
||||
return { record: attachment, deduplicated: false }
|
||||
} catch (error) {
|
||||
if (error instanceof LinearWriteFailure && error.kind === 'duplicate_id') {
|
||||
const attachment = await this.refetchLinearAttachmentAfterDuplicate(
|
||||
writeId,
|
||||
target.issue.id,
|
||||
target.workspaceId,
|
||||
() =>
|
||||
this.linearCreateStyleUnconfirmed('attach', writeId, target, {
|
||||
title,
|
||||
url: url.toString()
|
||||
}),
|
||||
options.signal
|
||||
)
|
||||
|
||||
return { record: attachment, deduplicated: true }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
({ target }, cause) =>
|
||||
this.linearCreateStyleUnconfirmed('attach', writeId, target, {
|
||||
title,
|
||||
url: url.toString(),
|
||||
cause
|
||||
}),
|
||||
cancellation
|
||||
)
|
||||
void this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier)
|
||||
return this.linearAttachResult(result.record, target, writeId, result.deduplicated)
|
||||
}
|
||||
|
||||
async linearCommentEdit(
|
||||
params: {
|
||||
commentId: string
|
||||
body: string
|
||||
workspaceId?: string
|
||||
},
|
||||
params: LinearCommentEditRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<LinearCommentEditResult> {
|
||||
if (params.body.length > LINEAR_WRITE_BODY_CAP) {
|
||||
@@ -170,15 +195,15 @@ export class RuntimeLinearCommentCommands extends RuntimeLinearCreateCommands {
|
||||
const {
|
||||
owner: { comment, workspaceId },
|
||||
result: edited
|
||||
} = await runLinearCommentAction(
|
||||
} = await this.runLinearAgentAction(
|
||||
(discoverySignal) => this.resolveLinearAgentComment(params, discoverySignal),
|
||||
(owner, options) =>
|
||||
updateLinearIssueCommentForAgent(owner.comment.id, params.body, owner.workspaceId, options),
|
||||
(owner) => this.linearCommentChangeUnconfirmed('edit', owner.comment, owner.workspaceId),
|
||||
(error) => this.mapLinearReadFailure(error),
|
||||
signal
|
||||
signal,
|
||||
(error) => this.mapLinearCommentWriteFailure(error)
|
||||
)
|
||||
await this.notifyLinearLinkedIssueUpdated(workspaceId, comment.issue.identifier)
|
||||
void this.notifyLinearLinkedIssueUpdated(workspaceId, comment.issue.identifier)
|
||||
return {
|
||||
comment: { id: edited.id, url: edited.url, parentId: edited.parentId },
|
||||
issue: this.linearWriteIssueRef(comment.issue),
|
||||
@@ -187,23 +212,20 @@ export class RuntimeLinearCommentCommands extends RuntimeLinearCreateCommands {
|
||||
}
|
||||
|
||||
async linearCommentDelete(
|
||||
params: {
|
||||
commentId: string
|
||||
workspaceId?: string
|
||||
},
|
||||
params: LinearCommentDeleteRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<LinearCommentDeleteResult> {
|
||||
const {
|
||||
owner: { comment, workspaceId }
|
||||
} = await runLinearCommentAction(
|
||||
} = await this.runLinearAgentAction(
|
||||
(discoverySignal) => this.resolveLinearAgentComment(params, discoverySignal),
|
||||
(owner, options) =>
|
||||
deleteLinearIssueCommentForAgent(owner.comment.id, owner.workspaceId, options),
|
||||
(owner) => this.linearCommentChangeUnconfirmed('delete', owner.comment, owner.workspaceId),
|
||||
(error) => this.mapLinearReadFailure(error),
|
||||
signal
|
||||
signal,
|
||||
(error) => this.mapLinearCommentWriteFailure(error)
|
||||
)
|
||||
await this.notifyLinearLinkedIssueUpdated(workspaceId, comment.issue.identifier)
|
||||
void this.notifyLinearLinkedIssueUpdated(workspaceId, comment.issue.identifier)
|
||||
return {
|
||||
comment: { id: comment.id, parentId: comment.parentId },
|
||||
issue: this.linearWriteIssueRef(comment.issue),
|
||||
@@ -213,10 +235,7 @@ export class RuntimeLinearCommentCommands extends RuntimeLinearCreateCommands {
|
||||
|
||||
// A UUID has one owner; unrelated unavailable workspaces must not delay its mutation.
|
||||
public async resolveLinearAgentComment(
|
||||
params: {
|
||||
commentId: string
|
||||
workspaceId?: string
|
||||
},
|
||||
params: LinearCommentDeleteRequest,
|
||||
signal: AbortSignal
|
||||
): Promise<{ comment: LinearAgentComment; workspaceId: string }> {
|
||||
if (!isLinearUuid(params.commentId)) {
|
||||
|
||||
@@ -259,15 +259,17 @@ describe('Linear comment edit and delete', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps add uncertainty while preventing an expired queued add from invoking Linear', async () => {
|
||||
it('reports no attempt while preventing an expired queued add from invoking Linear', async () => {
|
||||
vi.useFakeTimers()
|
||||
await Promise.all([acquire(), acquire(), acquire(), acquire()])
|
||||
try {
|
||||
const pending = createCommands().runLinearAgentWrite(
|
||||
(signal) => addIssueCommentForAgent('issue-1', 'body', 'w2', { id: COMMENT_ID, signal }),
|
||||
const pending = createCommands().runLinearAgentAction(
|
||||
async () => null,
|
||||
(_owner, options) =>
|
||||
addIssueCommentForAgent('issue-1', 'body', 'w2', { id: COMMENT_ID, ...options }),
|
||||
() => linearError('linear_write_unconfirmed', 'Add was not confirmed.')
|
||||
)
|
||||
const rejected = expect(pending).rejects.toMatchObject({ code: 'linear_write_unconfirmed' })
|
||||
const rejected = expect(pending).rejects.toMatchObject({ code: 'linear_timeout' })
|
||||
await vi.advanceTimersByTimeAsync(25_000)
|
||||
await rejected
|
||||
expect(mocks.createComment).not.toHaveBeenCalled()
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
import {
|
||||
isLinearAuthError,
|
||||
getLinearIssueCommentThreadRoot,
|
||||
LinearWriteFailure
|
||||
getLinearIssueCommentThreadRoot
|
||||
} from './runtime-linear-command-dependencies'
|
||||
import {
|
||||
LinearAgentAccessError,
|
||||
linearError,
|
||||
sanitizeLinearErrorMessage,
|
||||
toLinearAccessError
|
||||
} from '../linear/issue-context/errors'
|
||||
import { runLinearWriteAction } from './runtime-linear-write-action'
|
||||
import type { LinearWriteFailure } from '../linear/linear-issue-write-support'
|
||||
import { RuntimeLinearWriteResultCommands } from './runtime-linear-write-result-commands'
|
||||
|
||||
export class RuntimeLinearCommentLookupCommands extends RuntimeLinearWriteResultCommands {
|
||||
public async resolveLinearCommentParentId(
|
||||
issueId: string,
|
||||
commentId: string,
|
||||
workspaceId: string
|
||||
workspaceId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<string> {
|
||||
signal?.throwIfAborted()
|
||||
try {
|
||||
const root = await getLinearIssueCommentThreadRoot(issueId, commentId, workspaceId)
|
||||
const root = await getLinearIssueCommentThreadRoot(issueId, commentId, workspaceId, signal)
|
||||
if (!root) {
|
||||
throw linearError(
|
||||
'linear_invalid_parent',
|
||||
@@ -37,58 +39,31 @@ export class RuntimeLinearCommentLookupCommands extends RuntimeLinearWriteResult
|
||||
}
|
||||
}
|
||||
|
||||
public async runLinearAgentWrite<T>(
|
||||
write: (signal: AbortSignal) => Promise<T>,
|
||||
unconfirmed: (cause?: string) => LinearAgentAccessError
|
||||
): Promise<T> {
|
||||
const controller = new AbortController()
|
||||
const writePromise = write(controller.signal)
|
||||
writePromise.catch(() => undefined)
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
try {
|
||||
return await Promise.race([
|
||||
writePromise,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
controller.abort()
|
||||
reject(
|
||||
new LinearWriteFailure(
|
||||
'unconfirmed',
|
||||
'Linear write deadline elapsed before confirmation.'
|
||||
)
|
||||
)
|
||||
}, 25_000)
|
||||
})
|
||||
])
|
||||
} catch (error) {
|
||||
if (error instanceof LinearWriteFailure && error.kind === 'duplicate_id') {
|
||||
throw error
|
||||
}
|
||||
if (error instanceof LinearWriteFailure && error.kind === 'unconfirmed') {
|
||||
throw unconfirmed(this.linearWriteFailureCauseMessage(error))
|
||||
}
|
||||
if (error instanceof LinearWriteFailure && error.kind === 'network') {
|
||||
throw linearError('linear_network_error', sanitizeLinearErrorMessage(error.message))
|
||||
}
|
||||
if (error instanceof LinearWriteFailure) {
|
||||
throw linearError('linear_write_failed', sanitizeLinearErrorMessage(error.message))
|
||||
}
|
||||
throw this.mapLinearReadFailure(error)
|
||||
} finally {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
public runLinearAgentAction<Owner, Result>(
|
||||
prepare: (signal: AbortSignal) => Promise<Owner>,
|
||||
write: (
|
||||
owner: Owner,
|
||||
options: { signal: AbortSignal; beforeInvoke: () => void }
|
||||
) => Promise<Result>,
|
||||
unconfirmed: (owner: Owner, cause?: string) => LinearAgentAccessError,
|
||||
cancellation?: AbortSignal,
|
||||
mapWriteFailure?: (error: LinearWriteFailure) => LinearAgentAccessError
|
||||
): Promise<{ owner: Owner; result: Result }> {
|
||||
return runLinearWriteAction(
|
||||
prepare,
|
||||
write,
|
||||
unconfirmed,
|
||||
(error) => this.mapLinearReadFailure(error),
|
||||
cancellation,
|
||||
mapWriteFailure
|
||||
)
|
||||
}
|
||||
|
||||
public linearWriteFailureCauseMessage(error: LinearWriteFailure): string {
|
||||
if (error.cause instanceof Error) {
|
||||
return sanitizeLinearErrorMessage(error.cause.message)
|
||||
}
|
||||
if (error.cause !== undefined) {
|
||||
return sanitizeLinearErrorMessage(String(error.cause))
|
||||
}
|
||||
return sanitizeLinearErrorMessage(error.message)
|
||||
public mapLinearCommentWriteFailure(error: LinearWriteFailure): LinearAgentAccessError {
|
||||
return linearError(
|
||||
error.kind === 'network' ? 'linear_network_error' : 'linear_write_failed',
|
||||
'Linear could not complete the comment change. Retry after checking the comment.'
|
||||
)
|
||||
}
|
||||
|
||||
public mapLinearReadFailure(error: unknown): LinearAgentAccessError {
|
||||
|
||||
@@ -12,72 +12,83 @@ import type {
|
||||
import { RuntimeLinearSaveFieldCommands } from './runtime-linear-save-fields-commands'
|
||||
|
||||
export class RuntimeLinearCreateCommands extends RuntimeLinearSaveFieldCommands {
|
||||
async linearIssueCreate(params: {
|
||||
title: string
|
||||
body?: string
|
||||
teamInput?: string
|
||||
teamKey?: string
|
||||
state?: string
|
||||
assignee?: string
|
||||
priority?: number
|
||||
estimate?: number
|
||||
dueDate?: string
|
||||
labels?: string[]
|
||||
projectInput?: string
|
||||
parentInput?: string
|
||||
parentCurrent?: boolean
|
||||
workspaceId?: string
|
||||
writeId?: string
|
||||
context?: LinearCurrentIssueContextHints
|
||||
}): Promise<LinearCreateResult> {
|
||||
async linearIssueCreate(
|
||||
params: {
|
||||
title: string
|
||||
body?: string
|
||||
teamInput?: string
|
||||
teamKey?: string
|
||||
state?: string
|
||||
assignee?: string
|
||||
priority?: number
|
||||
estimate?: number
|
||||
dueDate?: string
|
||||
labels?: string[]
|
||||
projectInput?: string
|
||||
parentInput?: string
|
||||
parentCurrent?: boolean
|
||||
workspaceId?: string
|
||||
writeId?: string
|
||||
context?: LinearCurrentIssueContextHints
|
||||
},
|
||||
cancellation?: AbortSignal
|
||||
): Promise<LinearCreateResult> {
|
||||
if ((params.body?.length ?? 0) > LINEAR_WRITE_BODY_CAP) {
|
||||
throw linearError('linear_body_too_large', 'Linear issue body is too large.')
|
||||
}
|
||||
const parent =
|
||||
params.parentInput || params.parentCurrent
|
||||
? await this.resolveLinearAgentWriteTarget({
|
||||
input: params.parentInput,
|
||||
current: params.parentCurrent,
|
||||
workspaceId: params.workspaceId,
|
||||
context: params.context
|
||||
})
|
||||
: null
|
||||
if (parent && params.workspaceId && params.workspaceId !== parent.workspaceId) {
|
||||
throw linearError(
|
||||
'linear_invalid_workspace',
|
||||
'The parent issue belongs to a different workspace.'
|
||||
)
|
||||
}
|
||||
const team = await this.resolveLinearCreateTeam(
|
||||
params.teamInput ?? params.teamKey,
|
||||
params.workspaceId,
|
||||
parent
|
||||
)
|
||||
const createFields = await this.resolveLinearCreateFields(params, team)
|
||||
const parentId = parent?.issue.id ?? null
|
||||
const writeId = params.writeId ?? randomUUID()
|
||||
const existing =
|
||||
params.writeId !== undefined
|
||||
? await this.getMatchingLinearCreatedIssue(
|
||||
writeId,
|
||||
team.id,
|
||||
parentId,
|
||||
team.workspaceId,
|
||||
true,
|
||||
createFields
|
||||
const {
|
||||
owner: { team, parent },
|
||||
result
|
||||
} = await this.runLinearAgentAction(
|
||||
async (signal) => {
|
||||
const parent =
|
||||
params.parentInput || params.parentCurrent
|
||||
? await this.resolveLinearAgentWriteTarget(
|
||||
{
|
||||
input: params.parentInput,
|
||||
current: params.parentCurrent,
|
||||
workspaceId: params.workspaceId,
|
||||
context: params.context
|
||||
},
|
||||
signal
|
||||
)
|
||||
: null
|
||||
if (parent && params.workspaceId && params.workspaceId !== parent.workspaceId) {
|
||||
throw linearError(
|
||||
'linear_invalid_workspace',
|
||||
'The parent issue belongs to a different workspace.'
|
||||
)
|
||||
: null
|
||||
if (existing) {
|
||||
if (parent) {
|
||||
await this.notifyLinearLinkedIssueUpdated(parent.workspaceId, parent.issue.identifier)
|
||||
}
|
||||
return this.linearCreateResult(existing, team.workspaceId, writeId, true)
|
||||
}
|
||||
}
|
||||
const team = await this.resolveLinearCreateTeam(
|
||||
params.teamInput ?? params.teamKey,
|
||||
params.workspaceId,
|
||||
parent,
|
||||
signal
|
||||
)
|
||||
const createFields = await this.resolveLinearCreateFields(params, team, signal)
|
||||
const parentId = parent?.issue.id ?? null
|
||||
const existing =
|
||||
params.writeId !== undefined
|
||||
? await this.getMatchingLinearCreatedIssue(
|
||||
writeId,
|
||||
team.id,
|
||||
parentId,
|
||||
team.workspaceId,
|
||||
true,
|
||||
createFields,
|
||||
signal
|
||||
)
|
||||
: null
|
||||
return { team, parent, parentId, createFields, existing }
|
||||
},
|
||||
async ({ team, parent, parentId, createFields, existing }, options) => {
|
||||
if (existing) {
|
||||
return { issue: existing, deduplicated: true }
|
||||
}
|
||||
|
||||
try {
|
||||
const issue = await this.runLinearAgentWrite(
|
||||
async (signal) => {
|
||||
const created = await createLinearIssueForAgent(
|
||||
try {
|
||||
const issue = await createLinearIssueForAgent(
|
||||
team.id,
|
||||
params.title,
|
||||
params.body,
|
||||
@@ -86,54 +97,53 @@ export class RuntimeLinearCreateCommands extends RuntimeLinearSaveFieldCommands
|
||||
id: writeId,
|
||||
parentId,
|
||||
...createFields,
|
||||
signal
|
||||
...options
|
||||
}
|
||||
)
|
||||
if (!this.linearCreatedIssueMatchesIntent(created, createFields)) {
|
||||
if (!this.linearCreatedIssueMatchesIntent(issue, createFields)) {
|
||||
throw new LinearWriteFailure(
|
||||
'unconfirmed',
|
||||
'Linear issue create could not be confirmed with the requested task fields.'
|
||||
)
|
||||
}
|
||||
return created
|
||||
},
|
||||
(cause) =>
|
||||
this.linearCreateStyleUnconfirmed('create', writeId, null, {
|
||||
team,
|
||||
parent,
|
||||
title: params.title,
|
||||
bodyRequired: params.body !== undefined,
|
||||
createFields,
|
||||
cause
|
||||
})
|
||||
)
|
||||
if (parent) {
|
||||
await this.notifyLinearLinkedIssueUpdated(parent.workspaceId, parent.issue.identifier)
|
||||
}
|
||||
return this.linearCreateResult(issue, team.workspaceId, writeId, false)
|
||||
} catch (error) {
|
||||
if (error instanceof LinearWriteFailure && error.kind === 'duplicate_id') {
|
||||
const issue = await this.refetchLinearIssueAfterDuplicate(
|
||||
writeId,
|
||||
team.id,
|
||||
parentId,
|
||||
team.workspaceId,
|
||||
createFields,
|
||||
() =>
|
||||
this.linearCreateStyleUnconfirmed('create', writeId, null, {
|
||||
team,
|
||||
parent,
|
||||
title: params.title,
|
||||
bodyRequired: params.body !== undefined,
|
||||
createFields
|
||||
})
|
||||
)
|
||||
if (parent) {
|
||||
await this.notifyLinearLinkedIssueUpdated(parent.workspaceId, parent.issue.identifier)
|
||||
return { issue, deduplicated: false }
|
||||
} catch (error) {
|
||||
if (error instanceof LinearWriteFailure && error.kind === 'duplicate_id') {
|
||||
const issue = await this.refetchLinearIssueAfterDuplicate(
|
||||
writeId,
|
||||
team.id,
|
||||
parentId,
|
||||
team.workspaceId,
|
||||
createFields,
|
||||
() =>
|
||||
this.linearCreateStyleUnconfirmed('create', writeId, null, {
|
||||
team,
|
||||
parent,
|
||||
title: params.title,
|
||||
bodyRequired: params.body !== undefined,
|
||||
createFields
|
||||
}),
|
||||
options.signal
|
||||
)
|
||||
return { issue, deduplicated: true }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
return this.linearCreateResult(issue, team.workspaceId, writeId, true)
|
||||
}
|
||||
throw error
|
||||
},
|
||||
({ team, parent, createFields }, cause) =>
|
||||
this.linearCreateStyleUnconfirmed('create', writeId, null, {
|
||||
team,
|
||||
parent,
|
||||
title: params.title,
|
||||
bodyRequired: params.body !== undefined,
|
||||
createFields,
|
||||
cause
|
||||
}),
|
||||
cancellation
|
||||
)
|
||||
if (parent) {
|
||||
this.notifyLinearLinkedIssueUpdated(parent.workspaceId, parent.issue.identifier)
|
||||
}
|
||||
return this.linearCreateResult(result.issue, team.workspaceId, writeId, result.deduplicated)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,10 +18,12 @@ export class RuntimeLinearDedupeCommands extends RuntimeLinearCommentLookupComma
|
||||
issueId: string,
|
||||
parentId: string | null,
|
||||
workspaceId: string,
|
||||
required: boolean
|
||||
required: boolean,
|
||||
signal?: AbortSignal
|
||||
): Promise<Awaited<ReturnType<typeof getLinearCommentByUuidForAgent>> | null> {
|
||||
signal?.throwIfAborted()
|
||||
const comment = await this.readLinearWriteLookup(() =>
|
||||
getLinearCommentByUuidForAgent(writeId, workspaceId)
|
||||
getLinearCommentByUuidForAgent(writeId, workspaceId, signal)
|
||||
)
|
||||
if (!comment) {
|
||||
return null
|
||||
@@ -42,10 +44,12 @@ export class RuntimeLinearDedupeCommands extends RuntimeLinearCommentLookupComma
|
||||
writeId: string,
|
||||
issueId: string,
|
||||
workspaceId: string,
|
||||
required: boolean
|
||||
required: boolean,
|
||||
signal?: AbortSignal
|
||||
): Promise<Awaited<ReturnType<typeof getLinearAttachmentByUuidForAgent>> | null> {
|
||||
signal?.throwIfAborted()
|
||||
const attachment = await this.readLinearWriteLookup(() =>
|
||||
getLinearAttachmentByUuidForAgent(writeId, workspaceId)
|
||||
getLinearAttachmentByUuidForAgent(writeId, workspaceId, signal)
|
||||
)
|
||||
if (!attachment) {
|
||||
return null
|
||||
@@ -68,10 +72,12 @@ export class RuntimeLinearDedupeCommands extends RuntimeLinearCommentLookupComma
|
||||
parentId: string | null,
|
||||
workspaceId: string,
|
||||
required: boolean,
|
||||
intent: LinearCreateFieldIntent = {}
|
||||
intent: LinearCreateFieldIntent = {},
|
||||
signal?: AbortSignal
|
||||
): Promise<Awaited<ReturnType<typeof getLinearIssueByUuidForAgent>> | null> {
|
||||
signal?.throwIfAborted()
|
||||
const issue = await this.readLinearWriteLookup(() =>
|
||||
getLinearIssueByUuidForAgent(writeId, workspaceId)
|
||||
getLinearIssueByUuidForAgent(writeId, workspaceId, signal)
|
||||
)
|
||||
if (!issue) {
|
||||
return null
|
||||
@@ -97,8 +103,10 @@ export class RuntimeLinearDedupeCommands extends RuntimeLinearCommentLookupComma
|
||||
issueId: string,
|
||||
parentId: string | null,
|
||||
workspaceId: string,
|
||||
unconfirmed: (cause?: string) => LinearAgentAccessError
|
||||
unconfirmed: (cause?: string) => LinearAgentAccessError,
|
||||
signal?: AbortSignal
|
||||
): Promise<NonNullable<Awaited<ReturnType<typeof getLinearCommentByUuidForAgent>>>> {
|
||||
signal?.throwIfAborted()
|
||||
try {
|
||||
// Duplicate id may mean the original write landed; only the exact target relationship proves this retry.
|
||||
const comment = await this.getMatchingLinearCommentWrite(
|
||||
@@ -106,7 +114,8 @@ export class RuntimeLinearDedupeCommands extends RuntimeLinearCommentLookupComma
|
||||
issueId,
|
||||
parentId,
|
||||
workspaceId,
|
||||
true
|
||||
true,
|
||||
signal
|
||||
)
|
||||
if (comment) {
|
||||
return comment
|
||||
@@ -128,15 +137,18 @@ export class RuntimeLinearDedupeCommands extends RuntimeLinearCommentLookupComma
|
||||
writeId: string,
|
||||
issueId: string,
|
||||
workspaceId: string,
|
||||
unconfirmed: (cause?: string) => LinearAgentAccessError
|
||||
unconfirmed: (cause?: string) => LinearAgentAccessError,
|
||||
signal?: AbortSignal
|
||||
): Promise<NonNullable<Awaited<ReturnType<typeof getLinearAttachmentByUuidForAgent>>>> {
|
||||
signal?.throwIfAborted()
|
||||
try {
|
||||
// Duplicate id may mean the original write landed; only the exact target relationship proves this retry.
|
||||
const attachment = await this.getMatchingLinearAttachmentWrite(
|
||||
writeId,
|
||||
issueId,
|
||||
workspaceId,
|
||||
true
|
||||
true,
|
||||
signal
|
||||
)
|
||||
if (attachment) {
|
||||
return attachment
|
||||
@@ -160,8 +172,10 @@ export class RuntimeLinearDedupeCommands extends RuntimeLinearCommentLookupComma
|
||||
parentId: string | null,
|
||||
workspaceId: string,
|
||||
intent: LinearCreateFieldIntent,
|
||||
unconfirmed: (cause?: string) => LinearAgentAccessError
|
||||
unconfirmed: (cause?: string) => LinearAgentAccessError,
|
||||
signal?: AbortSignal
|
||||
): Promise<NonNullable<Awaited<ReturnType<typeof getLinearIssueByUuidForAgent>>>> {
|
||||
signal?.throwIfAborted()
|
||||
try {
|
||||
// Duplicate id may mean the original write landed; only the exact target relationship proves this retry.
|
||||
const issue = await this.getMatchingLinearCreatedIssue(
|
||||
@@ -170,7 +184,8 @@ export class RuntimeLinearDedupeCommands extends RuntimeLinearCommentLookupComma
|
||||
parentId,
|
||||
workspaceId,
|
||||
true,
|
||||
intent
|
||||
intent,
|
||||
signal
|
||||
)
|
||||
if (issue) {
|
||||
return issue
|
||||
|
||||
@@ -13,9 +13,11 @@ export class RuntimeLinearLabelWriteCommands extends RuntimeLinearProjectWriteCo
|
||||
public async resolveLinearLabelsForIssue(
|
||||
issue: NonNullable<Awaited<ReturnType<typeof getLinearIssueByUuidForAgent>>>,
|
||||
inputs: string[],
|
||||
workspaceId: string
|
||||
workspaceId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ id: string; name: string }[]> {
|
||||
const labels = await this.getLinearTeamLabelsForWrite(issue.team.id, workspaceId)
|
||||
signal?.throwIfAborted()
|
||||
const labels = await this.getLinearTeamLabelsForWrite(issue.team.id, workspaceId, signal)
|
||||
const resolved = inputs.map((input) => {
|
||||
const normalized = input.toLowerCase()
|
||||
const idMatch = labels.find((label) => label.id.toLowerCase() === normalized)
|
||||
@@ -43,9 +45,11 @@ export class RuntimeLinearLabelWriteCommands extends RuntimeLinearProjectWriteCo
|
||||
public async resolveLinearLabelsForTeam(
|
||||
teamId: string,
|
||||
inputs: string[],
|
||||
workspaceId: string
|
||||
workspaceId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ id: string; name: string }[]> {
|
||||
const labels = await this.getLinearTeamLabelsForWrite(teamId, workspaceId)
|
||||
signal?.throwIfAborted()
|
||||
const labels = await this.getLinearTeamLabelsForWrite(teamId, workspaceId, signal)
|
||||
const resolved = inputs.map((input) => {
|
||||
const normalized = input.toLowerCase()
|
||||
const idMatch = labels.find((label) => label.id.toLowerCase() === normalized)
|
||||
@@ -129,10 +133,12 @@ export class RuntimeLinearLabelWriteCommands extends RuntimeLinearProjectWriteCo
|
||||
}
|
||||
public async getLinearTeamLabelsForWrite(
|
||||
teamId: string,
|
||||
workspaceId: string
|
||||
workspaceId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<Awaited<ReturnType<typeof getLinearTeamLabelsOrThrow>>> {
|
||||
signal?.throwIfAborted()
|
||||
try {
|
||||
return await getLinearTeamLabelsOrThrow(teamId, workspaceId)
|
||||
return await getLinearTeamLabelsOrThrow(teamId, workspaceId, signal)
|
||||
} catch (error) {
|
||||
throw this.mapLinearReadFailure(error)
|
||||
}
|
||||
|
||||
@@ -14,38 +14,61 @@ import { RuntimeLinearTeamWriteCommands } from './runtime-linear-team-write-comm
|
||||
export class RuntimeLinearProjectWriteCommands extends RuntimeLinearTeamWriteCommands {
|
||||
public async resolveLinearCreateProject(
|
||||
input: string,
|
||||
team: { id: string; workspaceId: string }
|
||||
team: { id: string; workspaceId: string },
|
||||
signal?: AbortSignal
|
||||
): Promise<LinearProjectSummary> {
|
||||
signal?.throwIfAborted()
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed) {
|
||||
throw linearError('linear_invalid_project', 'Pass a non-empty Linear project id or name.')
|
||||
}
|
||||
const byId = isLinearUuid(trimmed)
|
||||
? await this.readLinearProjectByIdForCreate(trimmed, team.workspaceId)
|
||||
? await this.readLinearProjectByIdForCreate(trimmed, team.workspaceId, signal)
|
||||
: null
|
||||
if (byId) {
|
||||
await this.assertLinearProjectIncludesTeam(byId, team.id, team.workspaceId, trimmed)
|
||||
await this.assertLinearProjectIncludesTeam(byId, team.id, team.workspaceId, trimmed, signal)
|
||||
return byId
|
||||
}
|
||||
const searchCandidates = await this.readLinearProjectsForCreate(trimmed, team.workspaceId)
|
||||
const searchCandidates = await this.readLinearProjectsForCreate(
|
||||
trimmed,
|
||||
team.workspaceId,
|
||||
signal
|
||||
)
|
||||
const normalized = trimmed.toLowerCase()
|
||||
const idMatch = searchCandidates.find((project) => project.id.toLowerCase() === normalized)
|
||||
if (idMatch) {
|
||||
await this.assertLinearProjectIncludesTeam(idMatch, team.id, team.workspaceId, trimmed)
|
||||
await this.assertLinearProjectIncludesTeam(
|
||||
idMatch,
|
||||
team.id,
|
||||
team.workspaceId,
|
||||
trimmed,
|
||||
signal
|
||||
)
|
||||
return idMatch
|
||||
}
|
||||
const slugMatch = searchCandidates.find(
|
||||
(project) => project.slugId?.toLowerCase() === normalized
|
||||
)
|
||||
if (slugMatch) {
|
||||
await this.assertLinearProjectIncludesTeam(slugMatch, team.id, team.workspaceId, trimmed)
|
||||
await this.assertLinearProjectIncludesTeam(
|
||||
slugMatch,
|
||||
team.id,
|
||||
team.workspaceId,
|
||||
trimmed,
|
||||
signal
|
||||
)
|
||||
return slugMatch
|
||||
}
|
||||
const nameMatches = await this.readLinearProjectsByExactNameForCreate(trimmed, team.workspaceId)
|
||||
const nameMatches = await this.readLinearProjectsByExactNameForCreate(
|
||||
trimmed,
|
||||
team.workspaceId,
|
||||
signal
|
||||
)
|
||||
const compatibleNameMatches = await this.filterLinearProjectsForTeam(
|
||||
nameMatches,
|
||||
team.id,
|
||||
team.workspaceId
|
||||
team.workspaceId,
|
||||
signal
|
||||
)
|
||||
if (compatibleNameMatches.length === 1) {
|
||||
return compatibleNameMatches[0]
|
||||
@@ -65,7 +88,13 @@ export class RuntimeLinearProjectWriteCommands extends RuntimeLinearTeamWriteCom
|
||||
)
|
||||
}
|
||||
if (nameMatches.length > 0) {
|
||||
await this.assertLinearProjectIncludesTeam(nameMatches[0], team.id, team.workspaceId, trimmed)
|
||||
await this.assertLinearProjectIncludesTeam(
|
||||
nameMatches[0],
|
||||
team.id,
|
||||
team.workspaceId,
|
||||
trimmed,
|
||||
signal
|
||||
)
|
||||
}
|
||||
throw linearError('linear_invalid_project', `No Linear project exactly matched "${trimmed}".`, {
|
||||
projects: searchCandidates.map((project) => ({
|
||||
@@ -79,10 +108,12 @@ export class RuntimeLinearProjectWriteCommands extends RuntimeLinearTeamWriteCom
|
||||
|
||||
public async readLinearProjectByIdForCreate(
|
||||
id: string,
|
||||
workspaceId: string
|
||||
workspaceId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<LinearProjectSummary | null> {
|
||||
signal?.throwIfAborted()
|
||||
try {
|
||||
return await getLinearProject(id, workspaceId, true)
|
||||
return await getLinearProject(id, workspaceId, true, signal)
|
||||
} catch (error) {
|
||||
throw this.mapLinearReadFailure(error)
|
||||
}
|
||||
@@ -90,10 +121,13 @@ export class RuntimeLinearProjectWriteCommands extends RuntimeLinearTeamWriteCom
|
||||
|
||||
public async readLinearProjectsForCreate(
|
||||
query: string,
|
||||
workspaceId: string
|
||||
workspaceId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<LinearProjectSummary[]> {
|
||||
signal?.throwIfAborted()
|
||||
try {
|
||||
return (await listLinearProjects(query, LINEAR_SEARCH_MAX_LIMIT, workspaceId, true)).items
|
||||
return (await listLinearProjects(query, LINEAR_SEARCH_MAX_LIMIT, workspaceId, true, signal))
|
||||
.items
|
||||
} catch (error) {
|
||||
throw this.mapLinearReadFailure(error)
|
||||
}
|
||||
@@ -101,10 +135,12 @@ export class RuntimeLinearProjectWriteCommands extends RuntimeLinearTeamWriteCom
|
||||
|
||||
public async readLinearProjectsByExactNameForCreate(
|
||||
name: string,
|
||||
workspaceId: string
|
||||
workspaceId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<LinearProjectSummary[]> {
|
||||
signal?.throwIfAborted()
|
||||
try {
|
||||
return await listLinearProjectsByExactName(name, workspaceId, true)
|
||||
return await listLinearProjectsByExactName(name, workspaceId, true, signal)
|
||||
} catch (error) {
|
||||
throw this.mapLinearReadFailure(error)
|
||||
}
|
||||
@@ -114,15 +150,17 @@ export class RuntimeLinearProjectWriteCommands extends RuntimeLinearTeamWriteCom
|
||||
project: LinearProjectSummary,
|
||||
teamId: string,
|
||||
workspaceId: string,
|
||||
input: string
|
||||
input: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
signal?.throwIfAborted()
|
||||
if (this.linearProjectIncludesTeam(project, teamId)) {
|
||||
return
|
||||
}
|
||||
let teams: NonNullable<LinearProjectSummary['teams']> = []
|
||||
try {
|
||||
// Summary team lists are capped; page membership before refusing cross-team creates.
|
||||
teams = await listLinearProjectTeams(project.id, workspaceId, true)
|
||||
teams = await listLinearProjectTeams(project.id, workspaceId, true, signal)
|
||||
} catch (error) {
|
||||
throw this.mapLinearReadFailure(error)
|
||||
}
|
||||
@@ -142,8 +180,10 @@ export class RuntimeLinearProjectWriteCommands extends RuntimeLinearTeamWriteCom
|
||||
public async filterLinearProjectsForTeam(
|
||||
projects: LinearProjectSummary[],
|
||||
teamId: string,
|
||||
workspaceId: string
|
||||
workspaceId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<LinearProjectSummary[]> {
|
||||
signal?.throwIfAborted()
|
||||
const compatible: LinearProjectSummary[] = []
|
||||
for (const project of projects) {
|
||||
if (this.linearProjectIncludesTeam(project, teamId)) {
|
||||
@@ -151,7 +191,7 @@ export class RuntimeLinearProjectWriteCommands extends RuntimeLinearTeamWriteCom
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const teams = await listLinearProjectTeams(project.id, workspaceId, true)
|
||||
const teams = await listLinearProjectTeams(project.id, workspaceId, true, signal)
|
||||
if (teams.some((team) => team.id === teamId)) {
|
||||
compatible.push({ ...project, teams })
|
||||
}
|
||||
@@ -167,10 +207,12 @@ export class RuntimeLinearProjectWriteCommands extends RuntimeLinearTeamWriteCom
|
||||
}
|
||||
|
||||
public async getLinearViewerForWrite(
|
||||
workspaceId: string
|
||||
workspaceId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ id: string; displayName?: string | null; avatarUrl?: string | null }> {
|
||||
signal?.throwIfAborted()
|
||||
try {
|
||||
return await getLinearViewerForWorkspaceOrThrow(workspaceId)
|
||||
return await getLinearViewerForWorkspaceOrThrow(workspaceId, signal)
|
||||
} catch (error) {
|
||||
throw this.mapLinearReadFailure(error)
|
||||
}
|
||||
|
||||
@@ -84,7 +84,16 @@ export class RuntimeLinearRetryCommands extends RuntimeLinearCommandBase {
|
||||
return /^[A-Za-z0-9._:@%+=,/-]+$/.test(value) ? value : placeholder
|
||||
}
|
||||
|
||||
public async notifyLinearLinkedIssueUpdated(
|
||||
public notifyLinearLinkedIssueUpdated(
|
||||
workspaceId: string,
|
||||
identifier: string | readonly string[]
|
||||
): void {
|
||||
void this.refreshLinearLinkedIssues(workspaceId, identifier).catch((error: unknown) => {
|
||||
console.warn('[linear] Linked issue refresh failed:', error)
|
||||
})
|
||||
}
|
||||
|
||||
private async refreshLinearLinkedIssues(
|
||||
workspaceId: string,
|
||||
identifier: string | readonly string[]
|
||||
): Promise<void> {
|
||||
|
||||
@@ -13,7 +13,10 @@ import type {
|
||||
import { RuntimeLinearCommentCommands } from './runtime-linear-comment-commands'
|
||||
|
||||
export class RuntimeLinearSaveCommands extends RuntimeLinearCommentCommands {
|
||||
async linearSaveIssue(params: LinearSaveIssueRequest): Promise<LinearSaveIssueResult> {
|
||||
async linearSaveIssue(
|
||||
params: LinearSaveIssueRequest,
|
||||
cancellation?: AbortSignal
|
||||
): Promise<LinearSaveIssueResult> {
|
||||
if ((params.description?.length ?? 0) > LINEAR_WRITE_BODY_CAP) {
|
||||
throw linearError('linear_body_too_large', 'Linear issue body is too large.')
|
||||
}
|
||||
@@ -24,65 +27,77 @@ export class RuntimeLinearSaveCommands extends RuntimeLinearCommentCommands {
|
||||
'Creating with save-issue requires both team and title.'
|
||||
)
|
||||
}
|
||||
const created = await this.linearIssueCreate({
|
||||
title: params.title,
|
||||
body: params.description,
|
||||
teamInput: params.team,
|
||||
state: params.state,
|
||||
assignee: params.assignee ?? undefined,
|
||||
priority: params.priority,
|
||||
estimate: params.estimate ?? undefined,
|
||||
dueDate: params.dueDate ?? undefined,
|
||||
labels: params.labels,
|
||||
projectInput: params.project ?? undefined,
|
||||
parentInput: params.parentId ?? undefined,
|
||||
workspaceId: params.workspaceId,
|
||||
writeId: params.writeId,
|
||||
context: params.context
|
||||
})
|
||||
const created = await this.linearIssueCreate(
|
||||
{
|
||||
title: params.title,
|
||||
body: params.description,
|
||||
teamInput: params.team,
|
||||
state: params.state,
|
||||
assignee: params.assignee ?? undefined,
|
||||
priority: params.priority,
|
||||
estimate: params.estimate ?? undefined,
|
||||
dueDate: params.dueDate ?? undefined,
|
||||
labels: params.labels,
|
||||
projectInput: params.project ?? undefined,
|
||||
parentInput: params.parentId ?? undefined,
|
||||
workspaceId: params.workspaceId,
|
||||
writeId: params.writeId,
|
||||
context: params.context
|
||||
},
|
||||
cancellation
|
||||
)
|
||||
return { ...created, meta: { ...created.meta, created: true } }
|
||||
}
|
||||
if (params.team !== undefined) {
|
||||
throw linearError('linear_write_failed', 'Team can only be set when creating an issue.')
|
||||
}
|
||||
const target = await this.resolveLinearAgentWriteTarget(params)
|
||||
const current = await this.readLinearAgentIssueWriteRecord(target.issue.id, target.workspaceId)
|
||||
const fields = await this.buildLinearSaveUpdate(params, current, target.workspaceId)
|
||||
if (Object.keys(fields).length === 0) {
|
||||
throw linearError('linear_write_failed', 'No issue fields were provided to save.')
|
||||
}
|
||||
const alreadySet = await this.linearSavedIssueMatchesIntent(current, fields)
|
||||
const updated = alreadySet
|
||||
? current
|
||||
: await this.runLinearAgentWrite(
|
||||
async (signal) => {
|
||||
const saved = await updateLinearIssueForAgent(
|
||||
target.issue.id,
|
||||
fields,
|
||||
target.workspaceId,
|
||||
{ signal }
|
||||
)
|
||||
if (!(await this.linearSavedIssueMatchesIntent(saved, fields))) {
|
||||
throw new LinearWriteFailure(
|
||||
'unconfirmed',
|
||||
'Linear issue save could not be confirmed.'
|
||||
)
|
||||
}
|
||||
return saved
|
||||
},
|
||||
(cause) =>
|
||||
linearError(
|
||||
'linear_write_unconfirmed',
|
||||
'Linear may have applied the issue save, but Orca could not confirm it.',
|
||||
{
|
||||
nextSteps: [
|
||||
`Run \`orca linear issue ${target.issue.identifier} --workspace ${target.workspaceId} --json\` before retrying.`
|
||||
],
|
||||
...(cause ? { cause } : {})
|
||||
}
|
||||
)
|
||||
const {
|
||||
owner: { target },
|
||||
result: updated
|
||||
} = await this.runLinearAgentAction(
|
||||
async (signal) => {
|
||||
const target = await this.resolveLinearAgentWriteTarget(params, signal)
|
||||
const current = await this.readLinearAgentIssueWriteRecord(
|
||||
target.issue.id,
|
||||
target.workspaceId,
|
||||
signal
|
||||
)
|
||||
await this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier)
|
||||
const fields = await this.buildLinearSaveUpdate(params, current, target.workspaceId, signal)
|
||||
if (Object.keys(fields).length === 0) {
|
||||
throw linearError('linear_write_failed', 'No issue fields were provided to save.')
|
||||
}
|
||||
const alreadySet = await this.linearSavedIssueMatchesIntent(current, fields)
|
||||
return { target, current, fields, alreadySet }
|
||||
},
|
||||
async ({ target, current, fields, alreadySet }, options) => {
|
||||
if (alreadySet) {
|
||||
return current
|
||||
}
|
||||
const saved = await updateLinearIssueForAgent(
|
||||
target.issue.id,
|
||||
fields,
|
||||
target.workspaceId,
|
||||
options
|
||||
)
|
||||
if (!(await this.linearSavedIssueMatchesIntent(saved, fields))) {
|
||||
throw new LinearWriteFailure('unconfirmed', 'Linear issue save could not be confirmed.')
|
||||
}
|
||||
return saved
|
||||
},
|
||||
({ target }, cause) =>
|
||||
linearError(
|
||||
'linear_write_unconfirmed',
|
||||
'Linear may have applied the issue save, but Orca could not confirm it.',
|
||||
{
|
||||
nextSteps: [
|
||||
`Run \`orca linear issue ${target.issue.identifier} --workspace ${target.workspaceId} --json\` before retrying.`
|
||||
],
|
||||
...(cause ? { cause } : {})
|
||||
}
|
||||
),
|
||||
cancellation
|
||||
)
|
||||
void this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier)
|
||||
return {
|
||||
issue: updated,
|
||||
meta: {
|
||||
@@ -93,49 +108,59 @@ export class RuntimeLinearSaveCommands extends RuntimeLinearCommentCommands {
|
||||
}
|
||||
|
||||
async linearIssueUpdateTask(
|
||||
params: LinearIssueTaskUpdateRequest
|
||||
params: LinearIssueTaskUpdateRequest,
|
||||
cancellation?: AbortSignal
|
||||
): Promise<LinearIssueTaskUpdateResult> {
|
||||
const target = await this.resolveLinearAgentWriteTarget(params)
|
||||
const current = await this.readLinearAgentIssueWriteRecord(target.issue.id, target.workspaceId)
|
||||
const update = await this.buildLinearTaskUpdate(params, current, target.workspaceId)
|
||||
if (!update) {
|
||||
throw linearError('linear_write_failed', 'No Linear task field update was requested.')
|
||||
}
|
||||
const alreadySet = this.linearTaskFieldAlreadySet(params.operation, current, update)
|
||||
if (!alreadySet) {
|
||||
await this.runLinearAgentWrite(
|
||||
async (signal) => {
|
||||
const updated = await updateLinearIssueForAgent(
|
||||
target.issue.id,
|
||||
update.fields,
|
||||
target.workspaceId,
|
||||
{ signal }
|
||||
const {
|
||||
owner: { target, current, alreadySet },
|
||||
result: finalRecord
|
||||
} = await this.runLinearAgentAction(
|
||||
async (signal) => {
|
||||
const target = await this.resolveLinearAgentWriteTarget(params, signal)
|
||||
const current = await this.readLinearAgentIssueWriteRecord(
|
||||
target.issue.id,
|
||||
target.workspaceId,
|
||||
signal
|
||||
)
|
||||
const update = await this.buildLinearTaskUpdate(params, current, target.workspaceId, signal)
|
||||
if (!update) {
|
||||
throw linearError('linear_write_failed', 'No Linear task field update was requested.')
|
||||
}
|
||||
const alreadySet = this.linearTaskFieldAlreadySet(params.operation, current, update)
|
||||
return { target, current, update, alreadySet }
|
||||
},
|
||||
async ({ target, current, update, alreadySet }, options) => {
|
||||
if (alreadySet) {
|
||||
return current
|
||||
}
|
||||
const updated = await updateLinearIssueForAgent(
|
||||
target.issue.id,
|
||||
update.fields,
|
||||
target.workspaceId,
|
||||
options
|
||||
)
|
||||
if (!this.linearTaskFieldAlreadySet(params.operation, updated, update)) {
|
||||
throw new LinearWriteFailure(
|
||||
'unconfirmed',
|
||||
'Linear task field update could not be confirmed.'
|
||||
)
|
||||
if (!this.linearTaskFieldAlreadySet(params.operation, updated, update)) {
|
||||
throw new LinearWriteFailure(
|
||||
'unconfirmed',
|
||||
'Linear task field update could not be confirmed.'
|
||||
)
|
||||
}
|
||||
return updated
|
||||
},
|
||||
({ target }, cause) =>
|
||||
linearError(
|
||||
'linear_write_unconfirmed',
|
||||
'Linear may have applied the task update, but Orca could not confirm it.',
|
||||
{
|
||||
nextSteps: [
|
||||
`Run \`orca linear issue ${target.issue.identifier} --workspace ${target.workspaceId} --json\` and check the updated field before retrying.`
|
||||
],
|
||||
...(cause ? { cause } : {})
|
||||
}
|
||||
return updated
|
||||
},
|
||||
(cause) =>
|
||||
linearError(
|
||||
'linear_write_unconfirmed',
|
||||
'Linear may have applied the task update, but Orca could not confirm it.',
|
||||
{
|
||||
nextSteps: [
|
||||
`Run \`orca linear issue ${target.issue.identifier} --workspace ${target.workspaceId} --json\` and check the updated field before retrying.`
|
||||
],
|
||||
...(cause ? { cause } : {})
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
await this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier)
|
||||
const finalRecord = alreadySet
|
||||
? current
|
||||
: await this.readLinearAgentIssueWriteRecord(target.issue.id, target.workspaceId)
|
||||
),
|
||||
cancellation
|
||||
)
|
||||
void this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier)
|
||||
return this.linearTaskUpdateResult(
|
||||
params.operation,
|
||||
target.issue,
|
||||
|
||||
@@ -70,11 +70,13 @@ export class RuntimeLinearSaveFieldCommands extends RuntimeLinearTaskFieldComman
|
||||
labels?: string[]
|
||||
projectInput?: string
|
||||
},
|
||||
team: { id: string; workspaceId: string }
|
||||
team: { id: string; workspaceId: string },
|
||||
signal?: AbortSignal
|
||||
): Promise<LinearCreateFieldIntent> {
|
||||
signal?.throwIfAborted()
|
||||
const fields: LinearCreateFieldIntent = {}
|
||||
if (params.state) {
|
||||
const states = await this.getLinearTeamStatesForWrite(team.id, team.workspaceId)
|
||||
const states = await this.getLinearTeamStatesForWrite(team.id, team.workspaceId, signal)
|
||||
const state = this.resolveLinearAgentState(params.state, states)
|
||||
if (!state) {
|
||||
throw linearError(
|
||||
@@ -89,7 +91,8 @@ export class RuntimeLinearSaveFieldCommands extends RuntimeLinearTaskFieldComman
|
||||
fields.assigneeId = await this.resolveLinearAssignee(
|
||||
params.assignee,
|
||||
team.id,
|
||||
team.workspaceId
|
||||
team.workspaceId,
|
||||
signal
|
||||
)
|
||||
}
|
||||
if (params.priority !== undefined) {
|
||||
@@ -102,11 +105,16 @@ export class RuntimeLinearSaveFieldCommands extends RuntimeLinearTaskFieldComman
|
||||
fields.dueDate = params.dueDate
|
||||
}
|
||||
if (params.labels && params.labels.length > 0) {
|
||||
const labels = await this.resolveLinearLabelsForTeam(team.id, params.labels, team.workspaceId)
|
||||
const labels = await this.resolveLinearLabelsForTeam(
|
||||
team.id,
|
||||
params.labels,
|
||||
team.workspaceId,
|
||||
signal
|
||||
)
|
||||
fields.labelIds = labels.map((label) => label.id)
|
||||
}
|
||||
if (params.projectInput) {
|
||||
const project = await this.resolveLinearCreateProject(params.projectInput, team)
|
||||
const project = await this.resolveLinearCreateProject(params.projectInput, team, signal)
|
||||
fields.projectId = project.id
|
||||
}
|
||||
return fields
|
||||
|
||||
@@ -13,69 +13,76 @@ import type {
|
||||
import { RuntimeLinearSaveCommands } from './runtime-linear-save-commands'
|
||||
|
||||
export class RuntimeLinearStateCommands extends RuntimeLinearSaveCommands {
|
||||
async linearIssueSetState(params: {
|
||||
input?: string
|
||||
current?: boolean
|
||||
workspaceId?: string
|
||||
to: string
|
||||
context?: LinearCurrentIssueContextHints
|
||||
}): Promise<LinearStatusSetResult> {
|
||||
const target = await this.resolveLinearAgentWriteTarget(params)
|
||||
const teamId = target.issue.team?.id
|
||||
if (!teamId) {
|
||||
throw linearError('linear_invalid_state', 'The Linear issue does not have a team.')
|
||||
}
|
||||
const states = await this.getLinearTeamStatesForWrite(teamId, target.workspaceId)
|
||||
const state = this.resolveLinearAgentState(params.to, states)
|
||||
if (!state) {
|
||||
throw linearError(
|
||||
'linear_invalid_state',
|
||||
`No workflow state exactly matched "${params.to}".`,
|
||||
{
|
||||
states: states.map(({ id, name, type }) => ({ id, name, type })),
|
||||
nextSteps: [`Retry with one of the exact state names for ${target.issue.identifier}.`]
|
||||
async linearIssueSetState(
|
||||
params: {
|
||||
input?: string
|
||||
current?: boolean
|
||||
workspaceId?: string
|
||||
to: string
|
||||
context?: LinearCurrentIssueContextHints
|
||||
},
|
||||
cancellation?: AbortSignal
|
||||
): Promise<LinearStatusSetResult> {
|
||||
const {
|
||||
owner: { target, state, previousState, alreadyInState }
|
||||
} = await this.runLinearAgentAction(
|
||||
async (signal) => {
|
||||
const target = await this.resolveLinearAgentWriteTarget(params, signal)
|
||||
const teamId = target.issue.team?.id
|
||||
if (!teamId) {
|
||||
throw linearError('linear_invalid_state', 'The Linear issue does not have a team.')
|
||||
}
|
||||
const states = await this.getLinearTeamStatesForWrite(teamId, target.workspaceId, signal)
|
||||
const state = this.resolveLinearAgentState(params.to, states)
|
||||
if (!state) {
|
||||
throw linearError(
|
||||
'linear_invalid_state',
|
||||
`No workflow state exactly matched "${params.to}".`,
|
||||
{
|
||||
states: states.map(({ id, name, type }) => ({ id, name, type })),
|
||||
nextSteps: [`Retry with one of the exact state names for ${target.issue.identifier}.`]
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const previousState =
|
||||
target.issue.state?.id && target.issue.state.name
|
||||
? { id: target.issue.state.id, name: target.issue.state.name }
|
||||
: null
|
||||
const alreadyInState = target.issue.state?.id === state.id
|
||||
if (!alreadyInState) {
|
||||
await this.runLinearAgentWrite(
|
||||
async (signal) => {
|
||||
const updated = await updateLinearIssueForAgent(
|
||||
target.issue.id,
|
||||
{ stateId: state.id },
|
||||
target.workspaceId,
|
||||
{
|
||||
signal
|
||||
}
|
||||
)
|
||||
if (updated.state?.id !== state.id) {
|
||||
throw new LinearWriteFailure(
|
||||
'unconfirmed',
|
||||
'Linear state update could not be confirmed.'
|
||||
)
|
||||
const previousState =
|
||||
target.issue.state?.id && target.issue.state.name
|
||||
? { id: target.issue.state.id, name: target.issue.state.name }
|
||||
: null
|
||||
const alreadyInState = target.issue.state?.id === state.id
|
||||
return { target, state, previousState, alreadyInState }
|
||||
},
|
||||
async ({ target, state, alreadyInState }, options) => {
|
||||
if (alreadyInState) {
|
||||
return
|
||||
}
|
||||
const updated = await updateLinearIssueForAgent(
|
||||
target.issue.id,
|
||||
{ stateId: state.id },
|
||||
target.workspaceId,
|
||||
{
|
||||
...options
|
||||
}
|
||||
return updated
|
||||
},
|
||||
(cause) =>
|
||||
linearError(
|
||||
'linear_write_unconfirmed',
|
||||
'Linear may have applied the state change, but Orca could not confirm it.',
|
||||
{
|
||||
nextSteps: [
|
||||
`Run \`orca linear issue ${target.issue.identifier} --workspace ${target.workspaceId} --json\` and check the current state before retrying.`
|
||||
],
|
||||
...(cause ? { cause } : {})
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
await this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier)
|
||||
)
|
||||
if (updated.state?.id !== state.id) {
|
||||
throw new LinearWriteFailure('unconfirmed', 'Linear state update could not be confirmed.')
|
||||
}
|
||||
return updated
|
||||
},
|
||||
({ target }, cause) =>
|
||||
linearError(
|
||||
'linear_write_unconfirmed',
|
||||
'Linear may have applied the state change, but Orca could not confirm it.',
|
||||
{
|
||||
nextSteps: [
|
||||
`Run \`orca linear issue ${target.issue.identifier} --workspace ${target.workspaceId} --json\` and check the current state before retrying.`
|
||||
],
|
||||
...(cause ? { cause } : {})
|
||||
}
|
||||
),
|
||||
cancellation
|
||||
)
|
||||
void this.notifyLinearLinkedIssueUpdated(target.workspaceId, target.issue.identifier)
|
||||
return {
|
||||
issue: this.linearWriteIssueRef(target.issue),
|
||||
state: { id: state.id, name: state.name, type: state.type },
|
||||
@@ -85,50 +92,57 @@ export class RuntimeLinearStateCommands extends RuntimeLinearSaveCommands {
|
||||
}
|
||||
|
||||
async linearIssueRelationWrite(
|
||||
params: LinearIssueRelationWriteRequest
|
||||
params: LinearIssueRelationWriteRequest,
|
||||
cancellation?: AbortSignal
|
||||
): Promise<LinearIssueRelationWriteResult> {
|
||||
const target = await this.resolveLinearAgentWriteTarget(params)
|
||||
const related = await this.resolveLinearAgentWriteTarget({
|
||||
input: params.relatedInput,
|
||||
workspaceId: target.workspaceId,
|
||||
context: params.context
|
||||
})
|
||||
if (target.issue.id === related.issue.id) {
|
||||
throw linearError('linear_write_failed', 'An issue cannot be related to itself.')
|
||||
}
|
||||
try {
|
||||
const result = await this.runLinearAgentWrite(
|
||||
(signal) =>
|
||||
writeIssueRelation({
|
||||
issue: { ...this.linearWriteIssueRef(target.issue), title: target.issue.title },
|
||||
relatedIssue: {
|
||||
...this.linearWriteIssueRef(related.issue),
|
||||
title: related.issue.title
|
||||
},
|
||||
relationship: params.relationship,
|
||||
operation: params.operation,
|
||||
const {
|
||||
owner: { target, related },
|
||||
result
|
||||
} = await this.runLinearAgentAction(
|
||||
async (signal) => {
|
||||
const target = await this.resolveLinearAgentWriteTarget(params, signal)
|
||||
const related = await this.resolveLinearAgentWriteTarget(
|
||||
{
|
||||
input: params.relatedInput,
|
||||
workspaceId: target.workspaceId,
|
||||
signal
|
||||
}),
|
||||
(cause) =>
|
||||
linearError(
|
||||
'linear_write_unconfirmed',
|
||||
'Linear may have applied the relation change, but Orca could not confirm it.',
|
||||
{
|
||||
nextSteps: [
|
||||
`Run \`orca linear issue ${target.issue.identifier} --relations --workspace ${target.workspaceId} --json\` before retrying.`
|
||||
],
|
||||
...(cause ? { cause } : {})
|
||||
}
|
||||
)
|
||||
)
|
||||
await this.notifyLinearLinkedIssueUpdated(target.workspaceId, [
|
||||
target.issue.identifier,
|
||||
related.issue.identifier
|
||||
])
|
||||
return result
|
||||
} catch (error) {
|
||||
throw this.mapLinearReadFailure(error)
|
||||
}
|
||||
context: params.context
|
||||
},
|
||||
signal
|
||||
)
|
||||
if (target.issue.id === related.issue.id) {
|
||||
throw linearError('linear_write_failed', 'An issue cannot be related to itself.')
|
||||
}
|
||||
return { target, related }
|
||||
},
|
||||
({ target, related }, options) =>
|
||||
writeIssueRelation({
|
||||
issue: { ...this.linearWriteIssueRef(target.issue), title: target.issue.title },
|
||||
relatedIssue: {
|
||||
...this.linearWriteIssueRef(related.issue),
|
||||
title: related.issue.title
|
||||
},
|
||||
relationship: params.relationship,
|
||||
operation: params.operation,
|
||||
workspaceId: target.workspaceId,
|
||||
...options
|
||||
}),
|
||||
({ target }, cause) =>
|
||||
linearError(
|
||||
'linear_write_unconfirmed',
|
||||
'Linear may have applied the relation change, but Orca could not confirm it.',
|
||||
{
|
||||
nextSteps: [
|
||||
`Run \`orca linear issue ${target.issue.identifier} --relations --workspace ${target.workspaceId} --json\` before retrying.`
|
||||
],
|
||||
...(cause ? { cause } : {})
|
||||
}
|
||||
),
|
||||
cancellation
|
||||
)
|
||||
void this.notifyLinearLinkedIssueUpdated(target.workspaceId, [
|
||||
target.issue.identifier,
|
||||
related.issue.identifier
|
||||
])
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,10 +19,12 @@ import { RuntimeLinearLabelWriteCommands } from './runtime-linear-label-write-co
|
||||
export class RuntimeLinearTaskFieldCommands extends RuntimeLinearLabelWriteCommands {
|
||||
public async getLinearTeamStatesForWrite(
|
||||
teamId: string,
|
||||
workspaceId: string
|
||||
workspaceId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<Awaited<ReturnType<typeof getLinearTeamStatesOrThrow>>> {
|
||||
signal?.throwIfAborted()
|
||||
try {
|
||||
return await getLinearTeamStatesOrThrow(teamId, workspaceId)
|
||||
return await getLinearTeamStatesOrThrow(teamId, workspaceId, signal)
|
||||
} catch (error) {
|
||||
throw this.mapLinearReadFailure(error)
|
||||
}
|
||||
@@ -42,10 +44,12 @@ export class RuntimeLinearTaskFieldCommands extends RuntimeLinearLabelWriteComma
|
||||
|
||||
public async readLinearAgentIssueWriteRecord(
|
||||
issueId: string,
|
||||
workspaceId: string
|
||||
workspaceId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<NonNullable<Awaited<ReturnType<typeof getLinearIssueByUuidForAgent>>>> {
|
||||
signal?.throwIfAborted()
|
||||
const issue = await this.readLinearWriteLookup(() =>
|
||||
getLinearIssueByUuidForAgent(issueId, workspaceId)
|
||||
getLinearIssueByUuidForAgent(issueId, workspaceId, signal)
|
||||
)
|
||||
if (!issue) {
|
||||
throw linearError('linear_issue_not_found', 'Linear issue was not found.')
|
||||
@@ -56,7 +60,8 @@ export class RuntimeLinearTaskFieldCommands extends RuntimeLinearLabelWriteComma
|
||||
public async buildLinearTaskUpdate(
|
||||
params: LinearIssueTaskUpdateRequest,
|
||||
current: NonNullable<Awaited<ReturnType<typeof getLinearIssueByUuidForAgent>>>,
|
||||
workspaceId: string
|
||||
workspaceId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<{
|
||||
fields: {
|
||||
assigneeId?: string | null
|
||||
@@ -67,9 +72,10 @@ export class RuntimeLinearTaskFieldCommands extends RuntimeLinearLabelWriteComma
|
||||
}
|
||||
labels?: { id: string; name: string }[]
|
||||
} | null> {
|
||||
signal?.throwIfAborted()
|
||||
if (params.operation === 'assignee') {
|
||||
const assigneeId = params.assigneeMe
|
||||
? (await this.getLinearViewerForWrite(workspaceId)).id
|
||||
? (await this.getLinearViewerForWrite(workspaceId, signal)).id
|
||||
: params.assigneeId
|
||||
if (assigneeId === undefined) {
|
||||
throw linearError('linear_invalid_assignee', 'Pass --me, --to-id, or clear assignee.')
|
||||
@@ -100,7 +106,7 @@ export class RuntimeLinearTaskFieldCommands extends RuntimeLinearLabelWriteComma
|
||||
if (!mode || inputs.length === 0) {
|
||||
throw linearError('linear_invalid_label', 'Pass at least one --label.')
|
||||
}
|
||||
const labels = await this.resolveLinearLabelsForIssue(current, inputs, workspaceId)
|
||||
const labels = await this.resolveLinearLabelsForIssue(current, inputs, workspaceId, signal)
|
||||
const requestedIds = labels.map((label) => label.id)
|
||||
const existingIds = current.labelIds ?? current.labels?.map((label) => label.id) ?? []
|
||||
const nextIds =
|
||||
@@ -120,8 +126,10 @@ export class RuntimeLinearTaskFieldCommands extends RuntimeLinearLabelWriteComma
|
||||
public async buildLinearSaveUpdate(
|
||||
params: LinearSaveIssueRequest,
|
||||
current: NonNullable<Awaited<ReturnType<typeof getLinearIssueByUuidForAgent>>>,
|
||||
workspaceId: string
|
||||
workspaceId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<LinearIssueUpdate> {
|
||||
signal?.throwIfAborted()
|
||||
const fields: LinearIssueUpdate = {}
|
||||
if (params.title !== undefined) {
|
||||
fields.title = params.title
|
||||
@@ -139,7 +147,7 @@ export class RuntimeLinearTaskFieldCommands extends RuntimeLinearLabelWriteComma
|
||||
fields.dueDate = params.dueDate
|
||||
}
|
||||
if (params.state !== undefined) {
|
||||
const states = await this.getLinearTeamStatesForWrite(current.team.id, workspaceId)
|
||||
const states = await this.getLinearTeamStatesForWrite(current.team.id, workspaceId, signal)
|
||||
const state = this.resolveLinearAgentState(params.state, states)
|
||||
if (!state) {
|
||||
throw linearError(
|
||||
@@ -153,13 +161,18 @@ export class RuntimeLinearTaskFieldCommands extends RuntimeLinearLabelWriteComma
|
||||
fields.assigneeId =
|
||||
params.assignee === null
|
||||
? null
|
||||
: await this.resolveLinearAssignee(params.assignee, current.team.id, workspaceId)
|
||||
: await this.resolveLinearAssignee(params.assignee, current.team.id, workspaceId, signal)
|
||||
}
|
||||
if (params.labels !== undefined) {
|
||||
if (params.labels.length === 0) {
|
||||
fields.labelIds = []
|
||||
} else {
|
||||
const labels = await this.resolveLinearLabelsForIssue(current, params.labels, workspaceId)
|
||||
const labels = await this.resolveLinearLabelsForIssue(
|
||||
current,
|
||||
params.labels,
|
||||
workspaceId,
|
||||
signal
|
||||
)
|
||||
fields.labelIds = labels.map((label) => label.id)
|
||||
}
|
||||
}
|
||||
@@ -168,10 +181,14 @@ export class RuntimeLinearTaskFieldCommands extends RuntimeLinearLabelWriteComma
|
||||
params.project === null
|
||||
? null
|
||||
: (
|
||||
await this.resolveLinearCreateProject(params.project, {
|
||||
id: current.team.id,
|
||||
workspaceId
|
||||
})
|
||||
await this.resolveLinearCreateProject(
|
||||
params.project,
|
||||
{
|
||||
id: current.team.id,
|
||||
workspaceId
|
||||
},
|
||||
signal
|
||||
)
|
||||
).id
|
||||
}
|
||||
if (params.parentId !== undefined) {
|
||||
@@ -179,11 +196,14 @@ export class RuntimeLinearTaskFieldCommands extends RuntimeLinearLabelWriteComma
|
||||
params.parentId === null
|
||||
? null
|
||||
: (
|
||||
await this.resolveLinearAgentWriteTarget({
|
||||
input: params.parentId,
|
||||
workspaceId,
|
||||
context: params.context
|
||||
})
|
||||
await this.resolveLinearAgentWriteTarget(
|
||||
{
|
||||
input: params.parentId,
|
||||
workspaceId,
|
||||
context: params.context
|
||||
},
|
||||
signal
|
||||
)
|
||||
).issue.id
|
||||
if (fields.parentId === current.id) {
|
||||
throw linearError('linear_invalid_parent', 'An issue cannot be its own parent.')
|
||||
@@ -194,10 +214,12 @@ export class RuntimeLinearTaskFieldCommands extends RuntimeLinearLabelWriteComma
|
||||
public async resolveLinearAssignee(
|
||||
input: string,
|
||||
teamId: string,
|
||||
workspaceId: string
|
||||
workspaceId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<string> {
|
||||
signal?.throwIfAborted()
|
||||
if (input.toLowerCase() === 'me') {
|
||||
return (await this.getLinearViewerForWrite(workspaceId)).id
|
||||
return (await this.getLinearViewerForWrite(workspaceId, signal)).id
|
||||
}
|
||||
// Keep the existing direct-id fast path; avoid a paginated member scan.
|
||||
if (isLinearUuid(input)) {
|
||||
@@ -205,7 +227,7 @@ export class RuntimeLinearTaskFieldCommands extends RuntimeLinearLabelWriteComma
|
||||
}
|
||||
let members: Awaited<ReturnType<typeof getLinearTeamMembersOrThrow>>
|
||||
try {
|
||||
members = await getLinearTeamMembersOrThrow(teamId, workspaceId)
|
||||
members = await getLinearTeamMembersOrThrow(teamId, workspaceId, signal)
|
||||
} catch (error) {
|
||||
throw this.mapLinearReadFailure(error)
|
||||
}
|
||||
@@ -227,12 +249,16 @@ export class RuntimeLinearTaskFieldCommands extends RuntimeLinearLabelWriteComma
|
||||
: `Multiple team members exactly matched "${input}".`
|
||||
)
|
||||
}
|
||||
public async resolveLinearAgentWriteTarget(params: {
|
||||
input?: string
|
||||
current?: boolean
|
||||
workspaceId?: string
|
||||
context?: LinearCurrentIssueContextHints
|
||||
}): Promise<LinearAgentWriteTarget> {
|
||||
public async resolveLinearAgentWriteTarget(
|
||||
params: {
|
||||
input?: string
|
||||
current?: boolean
|
||||
workspaceId?: string
|
||||
context?: LinearCurrentIssueContextHints
|
||||
},
|
||||
signal?: AbortSignal
|
||||
): Promise<LinearAgentWriteTarget> {
|
||||
signal?.throwIfAborted()
|
||||
const result = await readLinearIssueContext(
|
||||
{
|
||||
input: params.input,
|
||||
@@ -248,7 +274,8 @@ export class RuntimeLinearTaskFieldCommands extends RuntimeLinearLabelWriteComma
|
||||
depth: 0,
|
||||
context: params.context
|
||||
},
|
||||
(context) => this.linearResolveCurrentIssue(context)
|
||||
(context) => this.linearResolveCurrentIssue(context, signal),
|
||||
signal
|
||||
)
|
||||
return { issue: result.issue, workspaceId: result.meta.resolved.workspaceId }
|
||||
}
|
||||
|
||||
@@ -77,7 +77,8 @@ export class RuntimeLinearTeamWriteCommands extends RuntimeLinearDedupeCommands
|
||||
|
||||
public async resolveLinearTeamInput(
|
||||
teamInput: string,
|
||||
workspaceId?: (string & {}) | 'all'
|
||||
workspaceId?: (string & {}) | 'all',
|
||||
signal?: AbortSignal
|
||||
): Promise<{
|
||||
id: string
|
||||
key: string
|
||||
@@ -85,10 +86,11 @@ export class RuntimeLinearTeamWriteCommands extends RuntimeLinearDedupeCommands
|
||||
workspaceId: string
|
||||
workspaceName?: string
|
||||
}> {
|
||||
signal?.throwIfAborted()
|
||||
this.validateLinearCreateWorkspaceScope(workspaceId === 'all' ? undefined : workspaceId)
|
||||
let teams: Awaited<ReturnType<typeof listLinearTeamsOrThrow>>
|
||||
try {
|
||||
teams = await listLinearTeamsOrThrow(workspaceId ?? 'all')
|
||||
teams = await listLinearTeamsOrThrow(workspaceId ?? 'all', signal)
|
||||
} catch (error) {
|
||||
throw this.mapLinearReadFailure(error)
|
||||
}
|
||||
@@ -127,8 +129,10 @@ export class RuntimeLinearTeamWriteCommands extends RuntimeLinearDedupeCommands
|
||||
public async resolveLinearCreateTeam(
|
||||
teamInput: string | undefined,
|
||||
workspaceId: string | undefined,
|
||||
parent: LinearAgentWriteTarget | null
|
||||
parent: LinearAgentWriteTarget | null,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ id: string; key: string; name: string; workspaceId: string }> {
|
||||
signal?.throwIfAborted()
|
||||
if (!teamInput && parent?.issue.team?.id && parent.issue.team.key && parent.issue.team.name) {
|
||||
return {
|
||||
id: parent.issue.team.id,
|
||||
@@ -147,7 +151,7 @@ export class RuntimeLinearTeamWriteCommands extends RuntimeLinearDedupeCommands
|
||||
this.validateLinearCreateWorkspaceScope(scope)
|
||||
let teams: Awaited<ReturnType<typeof listLinearTeamsOrThrow>>
|
||||
try {
|
||||
teams = await listLinearTeamsOrThrow(scope ?? 'all')
|
||||
teams = await listLinearTeamsOrThrow(scope ?? 'all', signal)
|
||||
} catch (error) {
|
||||
throw this.mapLinearReadFailure(error)
|
||||
}
|
||||
@@ -185,7 +189,7 @@ export class RuntimeLinearTeamWriteCommands extends RuntimeLinearDedupeCommands
|
||||
if (parent) {
|
||||
let globalTeams: Awaited<ReturnType<typeof listLinearTeamsOrThrow>>
|
||||
try {
|
||||
globalTeams = await listLinearTeamsOrThrow('all')
|
||||
globalTeams = await listLinearTeamsOrThrow('all', signal)
|
||||
} catch (error) {
|
||||
throw this.mapLinearReadFailure(error)
|
||||
}
|
||||
@@ -217,7 +221,8 @@ export class RuntimeLinearTeamWriteCommands extends RuntimeLinearDedupeCommands
|
||||
}
|
||||
}
|
||||
async linearResolveCurrentIssue(
|
||||
context?: LinearCurrentIssueContextHints
|
||||
context?: LinearCurrentIssueContextHints,
|
||||
signal?: AbortSignal
|
||||
): Promise<ReturnType<typeof getLinearCurrentIssueFromWorktree>> {
|
||||
if (!this.runtimeAvailable()) {
|
||||
throw new Error('runtime_unavailable')
|
||||
@@ -264,6 +269,7 @@ export class RuntimeLinearTeamWriteCommands extends RuntimeLinearDedupeCommands
|
||||
)
|
||||
}
|
||||
|
||||
signal?.throwIfAborted()
|
||||
const link = getLinearCurrentIssueFromWorktree(worktree)
|
||||
if (!link.workspaceId) {
|
||||
const backfill = resolveLegacyLinearLinkWorkspace(
|
||||
|
||||
+25
-8
@@ -1,25 +1,33 @@
|
||||
import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason'
|
||||
import { linearError, type LinearAgentAccessError } from '../linear/issue-context/errors'
|
||||
import {
|
||||
linearError,
|
||||
sanitizeLinearErrorMessage,
|
||||
type LinearAgentAccessError
|
||||
} from '../linear/issue-context/errors'
|
||||
import { LinearWriteFailure } from '../linear/linear-issue-write-support'
|
||||
|
||||
export async function runLinearCommentAction<Owner, Result>(
|
||||
export async function runLinearWriteAction<Owner, Result>(
|
||||
resolveOwner: (signal: AbortSignal) => Promise<Owner>,
|
||||
write: (
|
||||
owner: Owner,
|
||||
options: { signal: AbortSignal; beforeInvoke: () => void }
|
||||
) => Promise<Result>,
|
||||
unconfirmed: (owner: Owner) => LinearAgentAccessError,
|
||||
unconfirmed: (owner: Owner, cause?: string) => LinearAgentAccessError,
|
||||
mapFailure: (error: unknown) => LinearAgentAccessError,
|
||||
cancellation?: AbortSignal
|
||||
cancellation?: AbortSignal,
|
||||
mapWriteFailure?: (error: LinearWriteFailure) => LinearAgentAccessError
|
||||
): Promise<{ owner: Owner; result: Result }> {
|
||||
const deadline = Date.now() + 55_000
|
||||
const discoveryDeadline = Date.now() + 30_000
|
||||
const action = new AbortController()
|
||||
const signal = cancellation ? AbortSignal.any([action.signal, cancellation]) : action.signal
|
||||
let invoked = false
|
||||
const timeout = (): LinearAgentAccessError =>
|
||||
linearError(
|
||||
'linear_timeout',
|
||||
'The Linear comment action stopped before a change was attempted.'
|
||||
invoked
|
||||
? 'Linear write deadline elapsed before confirmation.'
|
||||
: 'The Linear action stopped before a change was attempted.'
|
||||
)
|
||||
const totalTimer = setTimeout(() => action.abort(timeout()), 55_000)
|
||||
const discovery = new AbortController()
|
||||
@@ -30,7 +38,6 @@ export async function runLinearCommentAction<Owner, Result>(
|
||||
}, 30_000)
|
||||
let writeTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let owner: Owner | undefined
|
||||
let invoked = false
|
||||
try {
|
||||
const discoverySignal = AbortSignal.any([signal, discovery.signal])
|
||||
try {
|
||||
@@ -63,15 +70,25 @@ export async function runLinearCommentAction<Owner, Result>(
|
||||
owner !== undefined &&
|
||||
(signal.aborted || (error instanceof LinearWriteFailure && error.kind === 'unconfirmed'))
|
||||
) {
|
||||
throw unconfirmed(owner)
|
||||
const cause = error instanceof LinearWriteFailure ? (error.cause ?? error) : error
|
||||
throw unconfirmed(
|
||||
owner,
|
||||
sanitizeLinearErrorMessage(cause instanceof Error ? cause.message : String(cause))
|
||||
)
|
||||
}
|
||||
if (signal.aborted || discoveryExpired) {
|
||||
throw timeout()
|
||||
}
|
||||
if (error instanceof LinearWriteFailure) {
|
||||
if (mapWriteFailure) {
|
||||
throw mapWriteFailure(error)
|
||||
}
|
||||
if (error.kind === 'duplicate_id') {
|
||||
throw error
|
||||
}
|
||||
throw linearError(
|
||||
error.kind === 'network' ? 'linear_network_error' : 'linear_write_failed',
|
||||
'Linear could not complete the comment change. Retry after checking the comment.'
|
||||
sanitizeLinearErrorMessage(error.message)
|
||||
)
|
||||
}
|
||||
throw mapFailure(error)
|
||||
@@ -0,0 +1,472 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import * as mutations from '../linear/linear-issue-mutations'
|
||||
import * as comments from '../linear/linear-issue-comments'
|
||||
import * as relations from '../linear/issue-relation-write'
|
||||
import { LinearWriteFailure } from '../linear/linear-issue-write-support'
|
||||
import { RuntimeLinearReadCommands } from './runtime-linear-read-commands'
|
||||
import type { LinearIssueTaskUpdateRequest } from './runtime-linear-command-dependencies'
|
||||
|
||||
const issue = {
|
||||
id: 'issue-1',
|
||||
identifier: 'ENG-1',
|
||||
title: 'Title',
|
||||
description: '',
|
||||
url: 'https://linear.app/acme/issue/ENG-1',
|
||||
team: { id: 'team-1', key: 'ENG', name: 'Engineering' },
|
||||
state: { id: 'todo', name: 'Todo' },
|
||||
parent: null,
|
||||
project: null,
|
||||
assignee: null,
|
||||
priority: 0,
|
||||
estimate: null,
|
||||
dueDate: null,
|
||||
labelIds: [],
|
||||
labels: []
|
||||
}
|
||||
const target = { issue, workspaceId: 'w1' }
|
||||
const team = { ...issue.team, workspaceId: 'w1' }
|
||||
|
||||
function commands(listResolvedWorktrees: () => Promise<[]> = async () => []) {
|
||||
const unusedSelector = async () => {
|
||||
throw new Error('unused')
|
||||
}
|
||||
const ports = {
|
||||
runtimeAvailable: () => true,
|
||||
showTerminal: async () => {
|
||||
throw new Error('unused')
|
||||
},
|
||||
resolveWorktreeSelector: unusedSelector,
|
||||
resolveWorkspaceSelector: unusedSelector,
|
||||
listResolvedWorktrees,
|
||||
setWorktreeMeta: () => {},
|
||||
emitClientEvent: () => {}
|
||||
}
|
||||
const result = new RuntimeLinearReadCommands(ports)
|
||||
vi.spyOn(result, 'resolveLinearAgentWriteTarget').mockResolvedValue(target)
|
||||
vi.spyOn(result, 'resolveLinearCreateTeam').mockResolvedValue(team)
|
||||
vi.spyOn(result, 'getLinearTeamStatesForWrite').mockResolvedValue([
|
||||
{ id: 'done', name: 'Done', type: 'completed', color: '#888888', position: 1 }
|
||||
])
|
||||
vi.spyOn(result, 'readLinearAgentIssueWriteRecord').mockResolvedValue(issue)
|
||||
vi.spyOn(result, 'getLinearViewerForWrite').mockResolvedValue({ id: 'user-1' })
|
||||
vi.spyOn(result, 'getLinearTeamLabelsForWrite').mockResolvedValue([
|
||||
{ id: 'label-1', name: 'Bug', color: '#888888' }
|
||||
])
|
||||
return result
|
||||
}
|
||||
|
||||
type Commands = ReturnType<typeof commands>
|
||||
const taskUpdates: LinearIssueTaskUpdateRequest[] = [
|
||||
{ operation: 'priority', priority: 2 },
|
||||
{ operation: 'assignee', assigneeMe: true },
|
||||
{ operation: 'labels', labelMode: 'add', labels: ['Bug'] },
|
||||
{ operation: 'estimate', estimate: 3 },
|
||||
{ operation: 'dueDate', dueDate: '2026-11-01' }
|
||||
]
|
||||
const actions: { name: string; run: (c: Commands, signal: AbortSignal) => Promise<unknown> }[] = [
|
||||
{
|
||||
name: 'status',
|
||||
run: (c, signal) => c.linearIssueSetState({ input: 'ENG-1', to: 'Done' }, signal)
|
||||
},
|
||||
...taskUpdates.map((update) => ({
|
||||
name: update.operation,
|
||||
run: (c: Commands, signal: AbortSignal) =>
|
||||
c.linearIssueUpdateTask({ ...update, input: 'ENG-1' }, signal)
|
||||
})),
|
||||
{
|
||||
name: 'save update',
|
||||
run: (c, signal) => c.linearSaveIssue({ input: 'ENG-1', title: 'New' }, signal)
|
||||
},
|
||||
{
|
||||
name: 'save create',
|
||||
run: (c, signal) => c.linearSaveIssue({ team: 'ENG', title: 'New' }, signal)
|
||||
},
|
||||
{
|
||||
name: 'create',
|
||||
run: (c, signal) => c.linearIssueCreate({ teamInput: 'ENG', title: 'New' }, signal)
|
||||
},
|
||||
{
|
||||
name: 'comment',
|
||||
run: (c, signal) => c.linearIssueAddComment({ input: 'ENG-1', body: 'body' }, signal)
|
||||
},
|
||||
{
|
||||
name: 'attach',
|
||||
run: (c, signal) =>
|
||||
c.linearIssueAttachLink({ input: 'ENG-1', url: 'https://example.com' }, signal)
|
||||
},
|
||||
...(['add', 'remove'] as const).map((operation) => ({
|
||||
name: `relation ${operation}`,
|
||||
run: (c: Commands, signal: AbortSignal) =>
|
||||
c.linearIssueRelationWrite(
|
||||
{ input: 'ENG-1', relatedInput: 'ENG-2', relationship: 'blocks', operation },
|
||||
signal
|
||||
)
|
||||
}))
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.spyOn(mutations, 'updateIssueForAgent').mockRejectedValue(new Error('unexpected mutation'))
|
||||
vi.spyOn(mutations, 'createIssueForAgent').mockRejectedValue(new Error('unexpected mutation'))
|
||||
vi.spyOn(comments, 'addIssueCommentForAgent').mockRejectedValue(new Error('unexpected mutation'))
|
||||
vi.spyOn(comments, 'createIssueAttachment').mockRejectedValue(new Error('unexpected mutation'))
|
||||
vi.spyOn(relations, 'writeIssueRelation').mockRejectedValue(new Error('unexpected mutation'))
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it.each(actions)('bounds $name preparation and never invokes after it expires', async ({ run }) => {
|
||||
const c = commands()
|
||||
let release = () => {}
|
||||
const held = new Promise<typeof target>((resolve) => {
|
||||
release = () => resolve(target)
|
||||
})
|
||||
vi.mocked(c.resolveLinearAgentWriteTarget).mockReturnValue(held)
|
||||
vi.mocked(c.resolveLinearCreateTeam).mockImplementation(async () => {
|
||||
await held
|
||||
return team
|
||||
})
|
||||
let outcome: unknown
|
||||
const pending = run(c, new AbortController().signal).catch((error) => {
|
||||
outcome = error
|
||||
})
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
expect(outcome).toMatchObject({
|
||||
code: 'linear_timeout',
|
||||
message: 'The Linear action stopped before a change was attempted.'
|
||||
})
|
||||
release()
|
||||
await pending
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(mutations.updateIssueForAgent).not.toHaveBeenCalled()
|
||||
expect(mutations.createIssueForAgent).not.toHaveBeenCalled()
|
||||
expect(comments.addIssueCommentForAgent).not.toHaveBeenCalled()
|
||||
expect(comments.createIssueAttachment).not.toHaveBeenCalled()
|
||||
expect(relations.writeIssueRelation).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
release()
|
||||
await pending
|
||||
}
|
||||
})
|
||||
|
||||
it.each(actions)('cancels $name preparation with the caller', async ({ run }) => {
|
||||
const c = commands()
|
||||
const controller = new AbortController()
|
||||
let release = () => {}
|
||||
const held = new Promise<typeof target>((resolve) => {
|
||||
release = () => resolve(target)
|
||||
})
|
||||
vi.mocked(c.resolveLinearAgentWriteTarget).mockReturnValue(held)
|
||||
vi.mocked(c.resolveLinearCreateTeam).mockImplementation(async () => {
|
||||
await held
|
||||
return team
|
||||
})
|
||||
let outcome: unknown
|
||||
const pending = run(c, controller.signal).catch((error) => {
|
||||
outcome = error
|
||||
})
|
||||
try {
|
||||
controller.abort()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(outcome).toMatchObject({ code: 'linear_timeout' })
|
||||
release()
|
||||
await pending
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(mutations.updateIssueForAgent).not.toHaveBeenCalled()
|
||||
expect(mutations.createIssueForAgent).not.toHaveBeenCalled()
|
||||
expect(comments.addIssueCommentForAgent).not.toHaveBeenCalled()
|
||||
expect(comments.createIssueAttachment).not.toHaveBeenCalled()
|
||||
expect(relations.writeIssueRelation).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
release()
|
||||
await pending
|
||||
}
|
||||
})
|
||||
|
||||
it('returns confirmed task fields without a second issue lookup', async () => {
|
||||
const c = commands()
|
||||
const confirmed = { ...issue, priority: 2 }
|
||||
vi.mocked(c.readLinearAgentIssueWriteRecord)
|
||||
.mockResolvedValueOnce(issue)
|
||||
.mockRejectedValue(new Error('unexpected second lookup'))
|
||||
vi.spyOn(mutations, 'updateIssueForAgent').mockResolvedValue(confirmed)
|
||||
await expect(
|
||||
c.linearIssueUpdateTask({ input: 'ENG-1', operation: 'priority', priority: 2 })
|
||||
).resolves.toMatchObject({ current: { priority: 2 }, meta: { alreadySet: false } })
|
||||
expect(c.readLinearAgentIssueWriteRecord).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.each(['stalled', 'rejected'])(
|
||||
'returns confirmed success while notification is %s',
|
||||
async (state) => {
|
||||
vi.spyOn(mutations, 'updateIssueForAgent').mockResolvedValue({
|
||||
...issue,
|
||||
state: { id: 'done', name: 'Done' }
|
||||
})
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
let release = () => {}
|
||||
const notification = new Promise<[]>((resolve) => {
|
||||
release = () => resolve([])
|
||||
})
|
||||
const c = commands(() =>
|
||||
state === 'stalled' ? notification : Promise.reject(new Error('notification unavailable'))
|
||||
)
|
||||
let outcome: unknown
|
||||
const pending = c.linearIssueSetState({ input: 'ENG-1', to: 'Done' }).then(
|
||||
(value) => {
|
||||
outcome = value
|
||||
},
|
||||
(error) => {
|
||||
outcome = error
|
||||
}
|
||||
)
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(outcome).toMatchObject({ state: { id: 'done' } })
|
||||
} finally {
|
||||
release()
|
||||
await pending
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['comment', 'attach', 'create'] as const)(
|
||||
'keeps %s duplicate-id recovery within the remaining action lifetime',
|
||||
async (verb) => {
|
||||
const writeId = '11111111-1111-4111-8111-111111111111'
|
||||
for (const expires of [false, true]) {
|
||||
const c = commands()
|
||||
const record = {
|
||||
id: writeId,
|
||||
url: issue.url,
|
||||
body: 'body',
|
||||
title: 'Link',
|
||||
issue,
|
||||
parentId: null,
|
||||
threadRootId: writeId
|
||||
}
|
||||
let releaseRecovery = () => {}
|
||||
let releaseMutation = () => {}
|
||||
const recovery = new Promise<void>((resolve) => {
|
||||
releaseRecovery = resolve
|
||||
})
|
||||
const mutation = new Promise<void>((resolve) => {
|
||||
releaseMutation = resolve
|
||||
})
|
||||
vi.spyOn(c, 'getMatchingLinearCommentWrite')
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockImplementation(async () => {
|
||||
await recovery
|
||||
return record
|
||||
})
|
||||
vi.spyOn(c, 'getMatchingLinearAttachmentWrite')
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockImplementation(async () => {
|
||||
await recovery
|
||||
return record
|
||||
})
|
||||
vi.spyOn(c, 'getMatchingLinearCreatedIssue')
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockImplementation(async () => {
|
||||
await recovery
|
||||
return { ...issue, id: writeId }
|
||||
})
|
||||
const duplicate = async (beforeInvoke?: () => void) => {
|
||||
beforeInvoke?.()
|
||||
await mutation
|
||||
throw new LinearWriteFailure('duplicate_id', 'Already exists')
|
||||
}
|
||||
vi.mocked(comments.addIssueCommentForAgent).mockImplementation(
|
||||
(_id, _body, _workspace, options) => duplicate(options.beforeInvoke)
|
||||
)
|
||||
vi.mocked(comments.createIssueAttachment).mockImplementation(
|
||||
(_id, _input, _workspace, options) => duplicate(options?.beforeInvoke)
|
||||
)
|
||||
vi.mocked(mutations.createIssueForAgent).mockImplementation(
|
||||
(_team, _title, _body, _workspace, options) => duplicate(options.beforeInvoke)
|
||||
)
|
||||
const invoke = () =>
|
||||
verb === 'comment'
|
||||
? c.linearIssueAddComment({ input: 'ENG-1', body: 'body', writeId })
|
||||
: verb === 'attach'
|
||||
? c.linearIssueAttachLink({ input: 'ENG-1', url: issue.url, writeId })
|
||||
: c.linearIssueCreate({ teamInput: 'ENG', title: 'Title', writeId })
|
||||
let outcome: unknown
|
||||
const pending = invoke().then(
|
||||
(value) => {
|
||||
outcome = value
|
||||
},
|
||||
(error) => {
|
||||
outcome = error
|
||||
}
|
||||
)
|
||||
const provider =
|
||||
verb === 'comment'
|
||||
? comments.addIssueCommentForAgent
|
||||
: verb === 'attach'
|
||||
? comments.createIssueAttachment
|
||||
: mutations.createIssueForAgent
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(20_000)
|
||||
releaseMutation()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
if (expires) {
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
expect(outcome).toMatchObject({
|
||||
code: 'linear_write_unconfirmed',
|
||||
data: {
|
||||
writeId,
|
||||
cause: 'Linear write deadline elapsed before confirmation.',
|
||||
nextSteps: [expect.stringContaining(`--write-id=${writeId}`)]
|
||||
}
|
||||
})
|
||||
} else {
|
||||
releaseRecovery()
|
||||
await pending
|
||||
expect(outcome).toMatchObject({ meta: { writeId, deduplicated: true } })
|
||||
}
|
||||
expect(provider).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
releaseMutation()
|
||||
releaseRecovery()
|
||||
await pending
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
}
|
||||
expect(provider).toHaveBeenCalledTimes(1)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
vi.mocked(provider).mockClear()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['write', 'total'] as const)(
|
||||
'reports a truthful cause when the %s deadline ends an invoked write',
|
||||
async (deadline) => {
|
||||
const c = commands()
|
||||
const timers = vi.spyOn(globalThis, 'setTimeout')
|
||||
let release = () => {}
|
||||
const confirmation = new Promise<typeof issue>((resolve) => {
|
||||
release = () => resolve({ ...issue, state: { id: 'done', name: 'Done' } })
|
||||
})
|
||||
vi.mocked(mutations.updateIssueForAgent).mockImplementation(
|
||||
(_id, _fields, _workspace, options) => {
|
||||
options?.beforeInvoke?.()
|
||||
return confirmation
|
||||
}
|
||||
)
|
||||
let outcome: unknown
|
||||
const pending = c.linearIssueSetState({ input: 'ENG-1', to: 'Done' }).catch((error) => {
|
||||
outcome = error
|
||||
})
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(mutations.updateIssueForAgent).toHaveBeenCalledTimes(1)
|
||||
if (deadline === 'write') {
|
||||
await vi.advanceTimersByTimeAsync(25_000)
|
||||
} else {
|
||||
const totalTimeout = timers.mock.calls.find(([, delay]) => delay === 55_000)?.[0]
|
||||
if (typeof totalTimeout !== 'function') {
|
||||
throw new Error('Total deadline not scheduled')
|
||||
}
|
||||
vi.setSystemTime(Date.now() + 55_000)
|
||||
// Deliver the real total callback independently of the earlier write timer.
|
||||
totalTimeout()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
}
|
||||
await pending
|
||||
expect(outcome).toMatchObject({
|
||||
code: 'linear_write_unconfirmed',
|
||||
data: {
|
||||
cause: 'Linear write deadline elapsed before confirmation.',
|
||||
nextSteps: [expect.stringContaining('orca linear issue ENG-1')]
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
release()
|
||||
await pending
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
}
|
||||
expect(mutations.updateIssueForAgent).toHaveBeenCalledTimes(1)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['status', 'relation'] as const)(
|
||||
'reports %s uncertainty only after invocation when the caller disconnects',
|
||||
async (verb) => {
|
||||
const c = commands()
|
||||
const caller = new AbortController()
|
||||
let release = () => {}
|
||||
const confirmation = new Promise<never>((_resolve, reject) => {
|
||||
release = () => reject(new Error('lost confirmation'))
|
||||
})
|
||||
vi.mocked(mutations.updateIssueForAgent).mockImplementation(
|
||||
(_id, _fields, _workspace, options) => {
|
||||
options?.beforeInvoke?.()
|
||||
return confirmation
|
||||
}
|
||||
)
|
||||
vi.mocked(relations.writeIssueRelation).mockImplementation((params) => {
|
||||
params.beforeInvoke?.()
|
||||
return confirmation
|
||||
})
|
||||
vi.mocked(c.resolveLinearAgentWriteTarget)
|
||||
.mockResolvedValueOnce(target)
|
||||
.mockResolvedValue({ ...target, issue: { ...issue, id: 'issue-2' } })
|
||||
const pending =
|
||||
verb === 'status'
|
||||
? c.linearIssueSetState({ input: 'ENG-1', to: 'Done' }, caller.signal)
|
||||
: c.linearIssueRelationWrite(
|
||||
{ input: 'ENG-1', relatedInput: 'ENG-2', relationship: 'blocks', operation: 'add' },
|
||||
caller.signal
|
||||
)
|
||||
const rejected = expect(pending).rejects.toMatchObject({
|
||||
code: 'linear_write_unconfirmed',
|
||||
data: { nextSteps: [expect.stringContaining('orca linear issue ENG-1')] }
|
||||
})
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
caller.abort()
|
||||
await rejected
|
||||
expect(
|
||||
verb === 'status' ? mutations.updateIssueForAgent : relations.writeIssueRelation
|
||||
).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
release()
|
||||
await pending.catch(() => {})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['comment', 'attach', 'create'] as const)(
|
||||
'returns a matching %s replay without attempting a mutation',
|
||||
async (verb) => {
|
||||
const c = commands()
|
||||
const writeId = '11111111-1111-4111-8111-111111111111'
|
||||
const record = {
|
||||
id: writeId,
|
||||
url: issue.url,
|
||||
body: 'body',
|
||||
title: 'Link',
|
||||
issue,
|
||||
parentId: null,
|
||||
threadRootId: writeId
|
||||
}
|
||||
vi.spyOn(c, 'getMatchingLinearCommentWrite').mockResolvedValue(record)
|
||||
vi.spyOn(c, 'getMatchingLinearAttachmentWrite').mockResolvedValue(record)
|
||||
vi.spyOn(c, 'getMatchingLinearCreatedIssue').mockResolvedValue({ ...issue, id: writeId })
|
||||
const pending =
|
||||
verb === 'comment'
|
||||
? c.linearIssueAddComment({ input: 'ENG-1', body: 'body', writeId })
|
||||
: verb === 'attach'
|
||||
? c.linearIssueAttachLink({ input: 'ENG-1', url: issue.url, writeId })
|
||||
: c.linearIssueCreate({ teamInput: 'ENG', title: 'Title', writeId })
|
||||
await expect(pending).resolves.toMatchObject({ meta: { writeId, deduplicated: true } })
|
||||
expect(comments.addIssueCommentForAgent).not.toHaveBeenCalled()
|
||||
expect(comments.createIssueAttachment).not.toHaveBeenCalled()
|
||||
expect(mutations.createIssueForAgent).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
@@ -11,47 +11,86 @@ import {
|
||||
installOrcadFeatureReadiness
|
||||
} from './rpc/orcad-feature-readiness'
|
||||
|
||||
it.each(['browser.eval', 'linear.commentEdit', 'linear.commentDelete'] as const)(
|
||||
'forwards local disconnect to %s without long-poll keepalives',
|
||||
async (method) => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'local-disconnect-forwarding-'))
|
||||
const entered = vi.fn()
|
||||
const server = new OrcaRuntimeRpcServer({
|
||||
runtime: new OrcaRuntimeService(),
|
||||
userDataPath,
|
||||
methods: [
|
||||
defineMethod({
|
||||
name: method,
|
||||
permission: 'workspace',
|
||||
params: null,
|
||||
handler: async (_params, { signal }) => {
|
||||
entered(signal)
|
||||
await new Promise<void>((resolve) =>
|
||||
signal?.addEventListener('abort', () => resolve(), { once: true })
|
||||
)
|
||||
return { cancelled: signal?.aborted }
|
||||
}
|
||||
})
|
||||
]
|
||||
})
|
||||
const cancellation = new AbortController()
|
||||
const startKeepalive = vi.fn()
|
||||
try {
|
||||
const pending = server['handleMessage'](
|
||||
JSON.stringify({ id: 'req-1', method, authToken: server['authToken'] }),
|
||||
{ signal: cancellation.signal, startKeepalive }
|
||||
)
|
||||
await vi.waitFor(() => expect(entered).toHaveBeenCalledWith(cancellation.signal))
|
||||
cancellation.abort()
|
||||
await expect(pending).resolves.toMatchObject({ ok: true, result: { cancelled: true } })
|
||||
expect(startKeepalive).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
cancellation.abort()
|
||||
await server.stop()
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
}
|
||||
it.each([
|
||||
'browser.eval',
|
||||
'linear.saveIssue',
|
||||
'linear.issueSetState',
|
||||
'linear.issueUpdateTask',
|
||||
'linear.issueRelationWrite',
|
||||
'linear.issueAddComment',
|
||||
'linear.issueAttachLink',
|
||||
'linear.issueCreate',
|
||||
'linear.commentEdit',
|
||||
'linear.commentDelete'
|
||||
] as const)('forwards local disconnect to %s without long-poll keepalives', async (method) => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'local-disconnect-forwarding-'))
|
||||
const entered = vi.fn()
|
||||
const server = new OrcaRuntimeRpcServer({
|
||||
runtime: new OrcaRuntimeService(),
|
||||
userDataPath,
|
||||
methods: [
|
||||
defineMethod({
|
||||
name: method,
|
||||
permission: 'workspace',
|
||||
params: null,
|
||||
handler: async (_params, { signal }) => {
|
||||
entered(signal)
|
||||
await new Promise<void>((resolve) =>
|
||||
signal?.addEventListener('abort', () => resolve(), { once: true })
|
||||
)
|
||||
return { cancelled: signal?.aborted }
|
||||
}
|
||||
})
|
||||
]
|
||||
})
|
||||
const cancellation = new AbortController()
|
||||
const startKeepalive = vi.fn()
|
||||
try {
|
||||
const pending = server['handleMessage'](
|
||||
JSON.stringify({ id: 'req-1', method, authToken: server['authToken'] }),
|
||||
{ signal: cancellation.signal, startKeepalive }
|
||||
)
|
||||
await vi.waitFor(() => expect(entered).toHaveBeenCalledWith(cancellation.signal))
|
||||
cancellation.abort()
|
||||
await expect(pending).resolves.toMatchObject({ ok: true, result: { cancelled: true } })
|
||||
expect(startKeepalive).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
cancellation.abort()
|
||||
await server.stop()
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves non-Linear unary signal ownership', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'linear-write-namespace-'))
|
||||
const server = new OrcaRuntimeRpcServer({
|
||||
runtime: new OrcaRuntimeService(),
|
||||
userDataPath,
|
||||
methods: [
|
||||
defineMethod({
|
||||
name: 'plugins.panelAction',
|
||||
permission: 'workspace',
|
||||
params: null,
|
||||
handler: async (_params, { signal }) => ({ hasSignal: signal !== undefined })
|
||||
})
|
||||
]
|
||||
})
|
||||
try {
|
||||
await expect(
|
||||
server['handleMessage'](
|
||||
JSON.stringify({
|
||||
id: 'req-1',
|
||||
method: 'plugins.panelAction',
|
||||
authToken: server['authToken']
|
||||
}),
|
||||
{ signal: new AbortController().signal, startKeepalive: vi.fn() }
|
||||
)
|
||||
).resolves.toMatchObject({ ok: true, result: { hasSignal: false } })
|
||||
} finally {
|
||||
await server.stop()
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
// A short RPC gets no handler signal, but a caller that disconnects mid feature-wait is never run.
|
||||
it('refuses a gated short RPC whose local caller disconnects while it waits', async () => {
|
||||
|
||||
@@ -45,7 +45,9 @@ export class RuntimeRpcRequestAdmission extends RuntimeRpcBinaryRouting {
|
||||
try {
|
||||
return await this.dispatcher.dispatch(request, {
|
||||
signal:
|
||||
longPoll || DISCONNECT_FORWARDED_METHODS.has(request.method)
|
||||
longPoll ||
|
||||
request.method.startsWith('linear.') ||
|
||||
DISCONNECT_FORWARDED_METHODS.has(request.method)
|
||||
? context?.signal
|
||||
: undefined,
|
||||
connectionSignal: context?.signal,
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
import '../../src/main/runtime/rpc/unused-default-rpc-methods.test-fixture'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { clearTimeout as clearRealTimeout, setTimeout as setRealTimeout } from 'node:timers'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { main } from '../../src/cli/index'
|
||||
import { RuntimeClient } from '../../src/cli/runtime-client'
|
||||
import * as mutations from '../../src/main/linear/linear-issue-mutations'
|
||||
import * as comments from '../../src/main/linear/linear-issue-comments'
|
||||
import * as relations from '../../src/main/linear/issue-relation-write'
|
||||
import { LinearWriteFailure } from '../../src/main/linear/linear-issue-write-support'
|
||||
import { OrcaRuntimeService } from '../../src/main/runtime/orca-runtime'
|
||||
import { OrcaRuntimeRpcServer } from '../../src/main/runtime/runtime-rpc'
|
||||
import { LINEAR_AGENT_ACCESS_METHODS } from '../../src/main/runtime/rpc/methods/linear-agent-access'
|
||||
|
||||
const writeId = '11111111-1111-4111-8111-111111111111'
|
||||
const issue = {
|
||||
id: 'issue-1',
|
||||
identifier: 'ENG-1',
|
||||
title: 'Title',
|
||||
description: '',
|
||||
url: 'https://linear.app/acme/issue/ENG-1',
|
||||
team: { id: 'team-1', key: 'ENG', name: 'Engineering' },
|
||||
state: { id: 'todo', name: 'Todo' },
|
||||
parent: null,
|
||||
project: null,
|
||||
assignee: null,
|
||||
priority: 0,
|
||||
estimate: null,
|
||||
dueDate: null,
|
||||
labelIds: [],
|
||||
labels: []
|
||||
}
|
||||
const target = { issue, workspaceId: 'w1' }
|
||||
const comment = {
|
||||
id: writeId,
|
||||
url: null,
|
||||
body: 'old',
|
||||
issue,
|
||||
parentId: null,
|
||||
threadRootId: writeId
|
||||
}
|
||||
let runtime: OrcaRuntimeService
|
||||
let server: OrcaRuntimeRpcServer
|
||||
let userDataPath: string
|
||||
let failure: LinearWriteFailure
|
||||
|
||||
async function observe<T>(pending: Promise<T>, phase: string): Promise<T> {
|
||||
let timer: ReturnType<typeof setRealTimeout> | undefined
|
||||
try {
|
||||
return await Promise.race([
|
||||
pending,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = setRealTimeout(() => reject(new Error(`Timed out observing ${phase}`)), 2_000)
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
clearRealTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
userDataPath = mkdtempSync(join(tmpdir(), 'linear-write-cli-'))
|
||||
vi.stubEnv('ORCA_USER_DATA_PATH', userDataPath)
|
||||
for (const name of [
|
||||
'ORCA_PAIRING_CODE',
|
||||
'ORCA_REMOTE_PAIRING',
|
||||
'ORCA_ENVIRONMENT',
|
||||
'ORCA_WORKTREE_ID',
|
||||
'ORCA_TERMINAL_HANDLE',
|
||||
'ORCA_RUNTIME_SOURCE_ID'
|
||||
]) {
|
||||
vi.stubEnv(name, '')
|
||||
}
|
||||
process.exitCode = 0
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
runtime = new OrcaRuntimeService()
|
||||
vi.spyOn(runtime, 'resolveLinearAgentWriteTarget').mockImplementation(async (params) =>
|
||||
params.input === 'ENG-2'
|
||||
? { ...target, issue: { ...issue, id: 'issue-2', identifier: 'ENG-2' } }
|
||||
: target
|
||||
)
|
||||
vi.spyOn(runtime, 'resolveLinearCreateTeam').mockResolvedValue({
|
||||
...issue.team,
|
||||
workspaceId: 'w1'
|
||||
})
|
||||
vi.spyOn(runtime, 'getLinearTeamStatesForWrite').mockResolvedValue([
|
||||
{ id: 'done', name: 'Done', type: 'completed', color: '#888888', position: 1 }
|
||||
])
|
||||
vi.spyOn(runtime, 'readLinearAgentIssueWriteRecord').mockResolvedValue(issue)
|
||||
vi.spyOn(runtime, 'getLinearTeamLabelsForWrite').mockResolvedValue([
|
||||
{ id: 'bug', name: 'Bug', color: '#888888' }
|
||||
])
|
||||
vi.spyOn(runtime, 'getLinearViewerForWrite').mockResolvedValue({ id: 'user-1' })
|
||||
vi.spyOn(runtime, 'resolveLinearAgentComment').mockResolvedValue({ comment, workspaceId: 'w1' })
|
||||
vi.spyOn(runtime, 'getMatchingLinearCommentWrite').mockResolvedValue(null)
|
||||
vi.spyOn(runtime, 'getMatchingLinearAttachmentWrite').mockResolvedValue(null)
|
||||
vi.spyOn(runtime, 'getMatchingLinearCreatedIssue').mockResolvedValue(null)
|
||||
vi.spyOn(runtime, 'notifyLinearLinkedIssueUpdated').mockReturnValue(undefined)
|
||||
failure = new LinearWriteFailure('unconfirmed', 'Confirmation lost')
|
||||
const lost = () => {
|
||||
throw failure
|
||||
}
|
||||
vi.spyOn(mutations, 'updateIssueForAgent').mockImplementation(
|
||||
async (_id, _fields, _workspace, options) => {
|
||||
options?.beforeInvoke?.()
|
||||
return lost()
|
||||
}
|
||||
)
|
||||
vi.spyOn(mutations, 'createIssueForAgent').mockImplementation(
|
||||
async (_team, _title, _body, _workspace, options) => {
|
||||
options.beforeInvoke?.()
|
||||
return lost()
|
||||
}
|
||||
)
|
||||
vi.spyOn(comments, 'addIssueCommentForAgent').mockImplementation(
|
||||
async (_id, _body, _workspace, options) => {
|
||||
options.beforeInvoke?.()
|
||||
return lost()
|
||||
}
|
||||
)
|
||||
vi.spyOn(comments, 'createIssueAttachment').mockImplementation(
|
||||
async (_id, _input, _workspace, options) => {
|
||||
options?.beforeInvoke?.()
|
||||
return lost()
|
||||
}
|
||||
)
|
||||
vi.spyOn(comments, 'updateIssueCommentForAgent').mockImplementation(
|
||||
async (_id, _body, _workspace, options) => {
|
||||
options?.beforeInvoke?.()
|
||||
return lost()
|
||||
}
|
||||
)
|
||||
vi.spyOn(comments, 'deleteIssueCommentForAgent').mockImplementation(
|
||||
async (_id, _workspace, options) => {
|
||||
options?.beforeInvoke?.()
|
||||
return lost()
|
||||
}
|
||||
)
|
||||
vi.spyOn(relations, 'writeIssueRelation').mockImplementation(async (params) => {
|
||||
params.beforeInvoke?.()
|
||||
return lost()
|
||||
})
|
||||
server = new OrcaRuntimeRpcServer({ runtime, userDataPath, methods: LINEAR_AGENT_ACCESS_METHODS })
|
||||
await server.start()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers()
|
||||
await server.stop()
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
process.exitCode = 0
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const verbs = [
|
||||
['status', 'set', 'ENG-1', '--to', 'Done'],
|
||||
['priority', 'set', 'ENG-1', '--to', 'high'],
|
||||
['priority', 'clear', 'ENG-1'],
|
||||
['assignee', 'set', 'ENG-1', '--to-id', writeId],
|
||||
['assignee', 'set', 'ENG-1', '--me'],
|
||||
['assignee', 'clear', 'ENG-1'],
|
||||
['label', 'add', 'ENG-1', '--label', 'Bug'],
|
||||
['label', 'remove', 'ENG-1', '--label', 'Bug'],
|
||||
['label', 'set', 'ENG-1', '--label', 'Bug'],
|
||||
['estimate', 'set', 'ENG-1', '--to', '3'],
|
||||
['estimate', 'clear', 'ENG-1'],
|
||||
['due-date', 'set', 'ENG-1', '--to', '2026-11-01'],
|
||||
['due-date', 'clear', 'ENG-1'],
|
||||
['relation', 'add', 'ENG-1', '--related', 'ENG-2', '--type', 'blocks'],
|
||||
['relation', 'remove', 'ENG-1', '--related', 'ENG-2', '--type', 'blocks'],
|
||||
['comment', 'add', 'ENG-1', '--body', 'new', '--write-id', writeId],
|
||||
['comment', 'edit', writeId, '--body', 'new'],
|
||||
['comment', 'delete', writeId],
|
||||
['attach', 'ENG-1', '--url', 'https://example.com', '--write-id', writeId],
|
||||
['create', '--title', 'New', '--team', 'ENG', '--write-id', writeId],
|
||||
['save-issue', 'ENG-1', '--title', 'New'],
|
||||
['save-issue', '--title', 'New', '--team', 'ENG', '--write-id', writeId]
|
||||
].map((argv) => ({ name: argv.join(' '), argv }))
|
||||
|
||||
it.each(verbs)(
|
||||
'preserves $name recovery through CLI, socket, admission and dispatch',
|
||||
async ({ argv }) => {
|
||||
if (argv[1] === 'clear' || argv[1] === 'remove') {
|
||||
vi.mocked(runtime.readLinearAgentIssueWriteRecord).mockResolvedValue({
|
||||
...issue,
|
||||
priority: 2,
|
||||
assignee: { id: 'user-2', displayName: 'Other' },
|
||||
estimate: 3,
|
||||
dueDate: '2026-11-01',
|
||||
labelIds: ['bug'],
|
||||
labels: [{ id: 'bug', name: 'Bug' }]
|
||||
})
|
||||
}
|
||||
await main(['linear', ...argv, '--json'], '/tmp')
|
||||
const envelope = JSON.parse(String(vi.mocked(console.log).mock.calls.at(-1)?.[0]))
|
||||
expect(envelope).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'linear_write_unconfirmed', data: { nextSteps: [expect.any(String)] } }
|
||||
})
|
||||
const nextSteps: string[] = envelope.error.data.nextSteps
|
||||
expect(nextSteps[0]).toContain('orca linear')
|
||||
if (argv.includes('--write-id')) {
|
||||
expect(nextSteps[0]).toContain(`--write-id=${writeId}`)
|
||||
}
|
||||
expect(process.exitCode).toBe(1)
|
||||
vi.mocked(console.error).mockClear()
|
||||
process.exitCode = 0
|
||||
await main(['linear', ...argv], '/tmp')
|
||||
const stderr = vi
|
||||
.mocked(console.error)
|
||||
.mock.calls.map((call) => String(call[0]))
|
||||
.join('\n')
|
||||
for (const step of nextSteps) {
|
||||
expect(stderr).toContain(step)
|
||||
}
|
||||
expect(process.exitCode).toBe(1)
|
||||
}
|
||||
)
|
||||
|
||||
const classifiedFailures = [
|
||||
{
|
||||
kind: 'failed',
|
||||
diagnostic: 'GraphQL validation error: Unknown argument body (BAD_USER_INPUT)'
|
||||
},
|
||||
{ kind: 'network', diagnostic: 'getaddrinfo ENOTFOUND api.linear.app' },
|
||||
{ kind: 'duplicate_id', diagnostic: 'Duplicate id on comment mutation (BAD_USER_INPUT)' }
|
||||
] as const
|
||||
const failureCases = verbs
|
||||
.filter(({ argv }) => argv[0] === 'comment' && ['edit', 'delete'].includes(argv[1]))
|
||||
.flatMap((command) => classifiedFailures.map((input) => ({ ...command, ...input })))
|
||||
const diagnosticControls = classifiedFailures
|
||||
.filter(({ kind }) => kind !== 'duplicate_id')
|
||||
.map((input) => ({ name: 'status set', argv: verbs[0].argv, ...input }))
|
||||
|
||||
it.each([...failureCases, ...diagnosticControls])(
|
||||
'preserves the prior $name $kind failure contract through the real CLI',
|
||||
async ({ argv, kind, diagnostic }) => {
|
||||
failure = new LinearWriteFailure(kind, diagnostic)
|
||||
const expectedMessage =
|
||||
argv[0] === 'comment'
|
||||
? 'Linear could not complete the comment change. Retry after checking the comment.'
|
||||
: diagnostic
|
||||
await main(['linear', ...argv, '--json'], '/tmp')
|
||||
const envelope = JSON.parse(String(vi.mocked(console.log).mock.calls.at(-1)?.[0]))
|
||||
expect(envelope).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: kind === 'network' ? 'linear_network_error' : 'linear_write_failed',
|
||||
message: expectedMessage
|
||||
}
|
||||
})
|
||||
expect(process.exitCode).toBe(1)
|
||||
vi.mocked(console.error).mockClear()
|
||||
process.exitCode = 0
|
||||
await main(['linear', ...argv], '/tmp')
|
||||
const stderr = vi
|
||||
.mocked(console.error)
|
||||
.mock.calls.map((call) => String(call[0]))
|
||||
.join('\n')
|
||||
expect(stderr).toContain(expectedMessage)
|
||||
if (argv[0] === 'comment') {
|
||||
expect(stderr).not.toContain(diagnostic)
|
||||
}
|
||||
expect(process.exitCode).toBe(1)
|
||||
}
|
||||
)
|
||||
|
||||
it('returns a confirmed CLI write over the unchanged wire payload', async () => {
|
||||
vi.mocked(mutations.updateIssueForAgent).mockImplementation(
|
||||
async (_id, _fields, _workspace, options) => {
|
||||
options?.beforeInvoke?.()
|
||||
return { ...issue, state: { id: 'done', name: 'Done' } }
|
||||
}
|
||||
)
|
||||
await main(['linear', 'status', 'set', 'ENG-1', '--to', 'Done', '--json'], '/tmp')
|
||||
expect(JSON.parse(String(vi.mocked(console.log).mock.calls.at(-1)?.[0]))).toMatchObject({
|
||||
ok: true,
|
||||
result: { state: { id: 'done' } }
|
||||
})
|
||||
expect(mutations.updateIssueForAgent).toHaveBeenCalledTimes(1)
|
||||
expect(process.exitCode).toBe(0)
|
||||
})
|
||||
|
||||
it('ends a stalled CLI preparation before its client timeout without a late write', async () => {
|
||||
let release = () => {}
|
||||
let entered = () => {}
|
||||
const started = new Promise<void>((resolve) => {
|
||||
entered = resolve
|
||||
})
|
||||
vi.mocked(runtime.resolveLinearAgentWriteTarget).mockImplementation(() => {
|
||||
entered()
|
||||
return new Promise((resolve) => {
|
||||
release = () => resolve(target)
|
||||
})
|
||||
})
|
||||
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
|
||||
const pending = main(['linear', 'status', 'set', 'ENG-1', '--to', 'Done', '--json'], '/tmp')
|
||||
try {
|
||||
await observe(started, 'preparation admission')
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
await observe(pending, 'CLI preparation timeout')
|
||||
expect(JSON.parse(String(vi.mocked(console.log).mock.calls.at(-1)?.[0]))).toMatchObject({
|
||||
error: {
|
||||
code: 'linear_timeout',
|
||||
message: 'The Linear action stopped before a change was attempted.'
|
||||
}
|
||||
})
|
||||
release()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(mutations.updateIssueForAgent).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
release()
|
||||
await observe(pending, 'CLI settlement')
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['status', 'duplicate comment'] as const)(
|
||||
'exposes truthful invoked deadline cause and recovery in %s CLI JSON',
|
||||
async (verb) => {
|
||||
let release = () => {}
|
||||
let entered = () => {}
|
||||
const held = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const started = new Promise<void>((resolve) => {
|
||||
entered = resolve
|
||||
})
|
||||
vi.mocked(mutations.updateIssueForAgent).mockImplementation(
|
||||
async (_id, _fields, _workspace, options) => {
|
||||
options?.beforeInvoke?.()
|
||||
entered()
|
||||
await held
|
||||
return { ...issue, state: { id: 'done', name: 'Done' } }
|
||||
}
|
||||
)
|
||||
vi.mocked(comments.addIssueCommentForAgent).mockImplementation(
|
||||
async (_id, _body, _workspace, options) => {
|
||||
options.beforeInvoke?.()
|
||||
throw new LinearWriteFailure('duplicate_id', 'Already exists')
|
||||
}
|
||||
)
|
||||
vi.mocked(runtime.getMatchingLinearCommentWrite)
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockImplementation(async () => {
|
||||
entered()
|
||||
await held
|
||||
return comment
|
||||
})
|
||||
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
|
||||
const argv =
|
||||
verb === 'status'
|
||||
? ['status', 'set', 'ENG-1', '--to', 'Done']
|
||||
: ['comment', 'add', 'ENG-1', '--body', 'new', '--write-id', writeId]
|
||||
const pending = main(['linear', ...argv, '--json'], '/tmp')
|
||||
pending.catch(() => {})
|
||||
try {
|
||||
await observe(started, 'invocation or duplicate recovery admission')
|
||||
await vi.advanceTimersByTimeAsync(25_000)
|
||||
await observe(pending, 'CLI confirmation deadline')
|
||||
const envelope = JSON.parse(String(vi.mocked(console.log).mock.calls.at(-1)?.[0]))
|
||||
expect(envelope).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'linear_write_unconfirmed',
|
||||
data: { cause: 'Linear write deadline elapsed before confirmation.' }
|
||||
}
|
||||
})
|
||||
if (verb === 'status') {
|
||||
expect(envelope.error.data.nextSteps).toEqual([
|
||||
expect.stringContaining('orca linear issue ENG-1')
|
||||
])
|
||||
} else {
|
||||
expect(envelope.error.data).toMatchObject({
|
||||
writeId,
|
||||
nextSteps: [expect.stringContaining(`--write-id=${writeId}`)]
|
||||
})
|
||||
expect(runtime.getMatchingLinearCommentWrite).toHaveBeenCalledTimes(2)
|
||||
}
|
||||
expect(process.exitCode).toBe(1)
|
||||
} finally {
|
||||
release()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await observe(pending, 'CLI settlement after late confirmation')
|
||||
}
|
||||
expect(
|
||||
verb === 'status' ? mutations.updateIssueForAgent : comments.addIssueCommentForAgent
|
||||
).toHaveBeenCalledTimes(1)
|
||||
expect(console.log).toHaveBeenCalledTimes(1)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
}
|
||||
)
|
||||
|
||||
it('propagates real client socket timeout into preparation cancellation', async () => {
|
||||
let release = () => {}
|
||||
let entered = () => {}
|
||||
let cancelled = () => {}
|
||||
const held = new Promise<typeof target>((resolve) => {
|
||||
release = () => resolve(target)
|
||||
})
|
||||
const started = new Promise<void>((resolve) => {
|
||||
entered = resolve
|
||||
})
|
||||
const aborted = new Promise<void>((resolve) => {
|
||||
cancelled = resolve
|
||||
})
|
||||
vi.mocked(runtime.resolveLinearAgentWriteTarget).mockImplementation((_params, signal) => {
|
||||
signal?.addEventListener('abort', () => cancelled(), { once: true })
|
||||
entered()
|
||||
return held
|
||||
})
|
||||
const dispatch = vi.spyOn(runtime, 'linearIssueSetState')
|
||||
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
|
||||
const client = new RuntimeClient(userDataPath, 30, null, null)
|
||||
const pending = client.call('linear.issueSetState', { input: 'ENG-1', to: 'Done' }).then(
|
||||
(value) => ({ value }),
|
||||
(error) => ({ error })
|
||||
)
|
||||
try {
|
||||
await observe(started, 'preparation admission before client timeout')
|
||||
expect(mutations.updateIssueForAgent).not.toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(30)
|
||||
expect(await observe(pending, 'client timeout')).toMatchObject({
|
||||
error: { code: 'runtime_timeout' }
|
||||
})
|
||||
await observe(aborted, 'socket-close cancellation at preparation')
|
||||
expect(mutations.updateIssueForAgent).not.toHaveBeenCalled()
|
||||
release()
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
expect(mutations.updateIssueForAgent).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
release()
|
||||
await vi.advanceTimersByTimeAsync(30)
|
||||
await observe(pending, 'client settlement')
|
||||
const request = dispatch.mock.results[0]
|
||||
if (request?.type === 'return') {
|
||||
await observe(
|
||||
Promise.resolve(request.value).catch(() => {}),
|
||||
'dispatch settlement'
|
||||
)
|
||||
}
|
||||
}
|
||||
expect(mutations.updateIssueForAgent).not.toHaveBeenCalled()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
Reference in New Issue
Block a user