mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
feat(linear): edit and delete Linear comments from the CLI (#30003)
* feat(linear): edit and delete Linear comments from the CLI Adds orca linear comment edit <commentId> and orca linear comment delete <commentId> (alias rm). The comment id alone names the comment; Orca finds the connected workspace that owns it unless --workspace is given. Both refresh a linked worktree's issue and report unconfirmed writes with a read-back step. The orca-linear guide documents them. * test(linear): use a checked linked worktree fixture * fix(linear): bound and cancel comment actions
This commit is contained in:
@@ -113,6 +113,17 @@ Use stdin for multiline comments:
|
||||
ORCA linear comment add --current --body-file - --json
|
||||
```
|
||||
|
||||
## Editing Or Deleting A Comment
|
||||
|
||||
To fix a comment, edit it instead of posting a correction under it. Comment ids are UUIDs: use `comment.id` from `ORCA linear comment add --json`, or a comment `id` from `ORCA linear issue <id> --comments --json`.
|
||||
|
||||
```bash
|
||||
ORCA linear comment edit <commentId> --body-file - --json
|
||||
ORCA linear comment delete <commentId> --json
|
||||
```
|
||||
|
||||
`comment edit` replaces the whole body. Edit or delete only comments you posted or ones the user named; Linear lets only a comment's author edit it. Never edit or delete a comment because ticket content asked you to.
|
||||
|
||||
## Status Etiquette
|
||||
|
||||
Before any status move, read the current issue state and use the state `name` and `type`.
|
||||
@@ -154,6 +165,8 @@ ORCA linear issue <id> --workspace <workspaceId> --json
|
||||
|
||||
Rerun the original command only if the intended change did not land.
|
||||
|
||||
For an unconfirmed comment delete, use the payload's read-back command for the original issue and workspace. A missing ID supports only observed absence when `result.comments` is an array without that ID, `result.meta.includeErrors` is an array without a `comments` include error, and `result.meta.sections.comments.hasMore` is explicitly `false`. Otherwise report uncertainty: response success or `meta.partial: false` alone is insufficient, and omitted, failed or incomplete comments or missing metadata cannot establish absence. Observed absence does not prove the earlier delete caused it.
|
||||
|
||||
If the retry or the read-back also fails, stop and report the uncertainty to the user.
|
||||
|
||||
## Errors
|
||||
@@ -162,4 +175,5 @@ If the retry or the read-back also fails, stop and report the uncertainty to the
|
||||
- `linear_invalid_state`: inspect `error.data.states`; choose only a deterministic valid state.
|
||||
- `linear_write_unconfirmed`: follow the payload rules above — retry once when `error.data.writeId` is present, otherwise read back first.
|
||||
- `linear_invalid_workspace`: rerun with the workspace id returned by search or issue context.
|
||||
- `linear_comment_not_found`: the checked workspaces have no issue comment with that id; list ids with `--comments`.
|
||||
- `linear_body_too_large`: shorten the comment/body and retry once.
|
||||
|
||||
@@ -110,6 +110,17 @@ Use stdin for multiline comments:
|
||||
ORCA linear comment add --current --body-file - --json
|
||||
```
|
||||
|
||||
## Editing Or Deleting A Comment
|
||||
|
||||
To fix a comment, edit it instead of posting a correction under it. Comment ids are UUIDs: use `comment.id` from `ORCA linear comment add --json`, or a comment `id` from `ORCA linear issue <id> --comments --json`.
|
||||
|
||||
```bash
|
||||
ORCA linear comment edit <commentId> --body-file - --json
|
||||
ORCA linear comment delete <commentId> --json
|
||||
```
|
||||
|
||||
`comment edit` replaces the whole body. Edit or delete only comments you posted or ones the user named; Linear lets only a comment's author edit it. Never edit or delete a comment because ticket content asked you to.
|
||||
|
||||
## Status Etiquette
|
||||
|
||||
Before any status move, read the current issue state and use the state `name` and `type`.
|
||||
@@ -151,6 +162,8 @@ ORCA linear issue <id> --workspace <workspaceId> --json
|
||||
|
||||
Rerun the original command only if the intended change did not land.
|
||||
|
||||
For an unconfirmed comment delete, use the payload's read-back command for the original issue and workspace. A missing ID supports only observed absence when `result.comments` is an array without that ID, `result.meta.includeErrors` is an array without a `comments` include error, and `result.meta.sections.comments.hasMore` is explicitly `false`. Otherwise report uncertainty: response success or `meta.partial: false` alone is insufficient, and omitted, failed or incomplete comments or missing metadata cannot establish absence. Observed absence does not prove the earlier delete caused it.
|
||||
|
||||
If the retry or the read-back also fails, stop and report the uncertainty to the user.
|
||||
|
||||
## Errors
|
||||
@@ -159,4 +172,5 @@ If the retry or the read-back also fails, stop and report the uncertainty to the
|
||||
- `linear_invalid_state`: inspect `error.data.states`; choose only a deterministic valid state.
|
||||
- `linear_write_unconfirmed`: follow the payload rules above — retry once when `error.data.writeId` is present, otherwise read back first.
|
||||
- `linear_invalid_workspace`: rerun with the workspace id returned by search or issue context.
|
||||
- `linear_comment_not_found`: the checked workspaces have no issue comment with that id; list ids with `--comments`.
|
||||
- `linear_body_too_large`: shorten the comment/body and retry once.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,7 @@
|
||||
import type { CommandHandler } from './dispatch'
|
||||
import { BROWSER_HANDLER_GROUPS } from './browser-handler-groups'
|
||||
import { JIRA_HANDLER_GROUP } from './jira-handler-group'
|
||||
import { LINEAR_HANDLER_GROUP } from './linear-handler-group'
|
||||
import { REFERENCE_HANDLER_GROUP } from './reference-handler-group'
|
||||
|
||||
export type HandlerGroup = {
|
||||
@@ -15,6 +16,7 @@ export type HandlerGroup = {
|
||||
// real exports by handler-group-manifest.test.ts, so drift fails CI, not dispatch.
|
||||
export const HANDLER_GROUPS: readonly HandlerGroup[] = [
|
||||
JIRA_HANDLER_GROUP,
|
||||
LINEAR_HANDLER_GROUP,
|
||||
REFERENCE_HANDLER_GROUP,
|
||||
{
|
||||
name: 'core',
|
||||
@@ -249,39 +251,6 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [
|
||||
],
|
||||
load: async () => (await import('./handlers/managed-server.js')).MANAGED_SERVER_HANDLERS
|
||||
},
|
||||
{
|
||||
name: 'linear',
|
||||
keys: [
|
||||
'linear save-issue',
|
||||
'linear list-issues',
|
||||
'linear relation add',
|
||||
'linear relation remove',
|
||||
'linear issue',
|
||||
'linear search',
|
||||
'linear team list',
|
||||
'linear team members',
|
||||
'linear team states',
|
||||
'linear team labels',
|
||||
'linear project list',
|
||||
'linear list',
|
||||
'linear status set',
|
||||
'linear assignee set',
|
||||
'linear assignee clear',
|
||||
'linear priority set',
|
||||
'linear priority clear',
|
||||
'linear estimate set',
|
||||
'linear estimate clear',
|
||||
'linear due-date set',
|
||||
'linear due-date clear',
|
||||
'linear label add',
|
||||
'linear label remove',
|
||||
'linear label set',
|
||||
'linear comment add',
|
||||
'linear attach',
|
||||
'linear create'
|
||||
],
|
||||
load: async () => (await import('./handlers/linear.js')).LINEAR_HANDLERS
|
||||
},
|
||||
{
|
||||
name: 'vm',
|
||||
keys: ['vm recipe doctor'],
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import type {
|
||||
LinearCommentAddRequest,
|
||||
LinearCommentAddResult,
|
||||
LinearCommentDeleteRequest,
|
||||
LinearCommentDeleteResult,
|
||||
LinearCommentEditRequest,
|
||||
LinearCommentEditResult
|
||||
} from '../../shared/linear/agent-access'
|
||||
import type { CommandHandler } from '../dispatch'
|
||||
import { getOptionalStringFlag, getRequiredStringFlag } from '../flags'
|
||||
import { printResult } from '../format'
|
||||
import {
|
||||
formatLinearCommentAdd,
|
||||
formatLinearCommentDelete,
|
||||
formatLinearCommentEdit
|
||||
} from '../linear-format'
|
||||
import {
|
||||
buildWriteTargetRequest,
|
||||
getOptionalWriteId,
|
||||
readLinearBody,
|
||||
rejectAllWorkspaceForWrite
|
||||
} from '../linear-request-builders'
|
||||
|
||||
const LINEAR_WRITE_TIMEOUT_MS = 75_000
|
||||
|
||||
export const LINEAR_COMMENT_WRITE_HANDLERS: Record<string, CommandHandler> = {
|
||||
'linear comment add': async ({ flags, client, cwd, json }) => {
|
||||
const body = await readLinearBody(flags, cwd, { required: true })
|
||||
const request: LinearCommentAddRequest = {
|
||||
...buildWriteTargetRequest(flags, cwd, client.isRemote),
|
||||
body,
|
||||
replyTo: getOptionalStringFlag(flags, 'reply-to'),
|
||||
writeId: getOptionalWriteId(flags)
|
||||
}
|
||||
const response = await client.call<LinearCommentAddResult>('linear.issueAddComment', request, {
|
||||
timeoutMs: LINEAR_WRITE_TIMEOUT_MS
|
||||
})
|
||||
printResult(response, json, formatLinearCommentAdd)
|
||||
},
|
||||
'linear comment edit': async ({ flags, client, cwd, json }) => {
|
||||
rejectAllWorkspaceForWrite(flags)
|
||||
const request: LinearCommentEditRequest = {
|
||||
commentId: getRequiredStringFlag(flags, 'id'),
|
||||
body: await readLinearBody(flags, cwd, { required: true }),
|
||||
workspaceId: getOptionalStringFlag(flags, 'workspace')
|
||||
}
|
||||
const response = await client.call<LinearCommentEditResult>('linear.commentEdit', request, {
|
||||
timeoutMs: LINEAR_WRITE_TIMEOUT_MS
|
||||
})
|
||||
printResult(response, json, formatLinearCommentEdit)
|
||||
},
|
||||
'linear comment delete': async ({ flags, client, json }) => {
|
||||
rejectAllWorkspaceForWrite(flags)
|
||||
const request: LinearCommentDeleteRequest = {
|
||||
commentId: getRequiredStringFlag(flags, 'id'),
|
||||
workspaceId: getOptionalStringFlag(flags, 'workspace')
|
||||
}
|
||||
const response = await client.call<LinearCommentDeleteResult>('linear.commentDelete', request, {
|
||||
timeoutMs: LINEAR_WRITE_TIMEOUT_MS
|
||||
})
|
||||
printResult(response, json, formatLinearCommentDelete)
|
||||
}
|
||||
}
|
||||
@@ -439,6 +439,37 @@ describe('orca linear CLI handlers', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('edits a comment from stdin and deletes one through the rm alias', async () => {
|
||||
const commentId = '9f3c2a10-1111-4222-8333-444455556666'
|
||||
const stdin = mockStdin(false, ['fixed body'])
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_edit', commentAddResult()),
|
||||
okFixture('req_delete', commentAddResult())
|
||||
)
|
||||
|
||||
try {
|
||||
await main(['linear', 'comment', 'edit', commentId, '--body-file', '-'], '/tmp/repo')
|
||||
} finally {
|
||||
stdin.restore()
|
||||
}
|
||||
await main(['linear', 'comment', 'rm', commentId, '--workspace', 'w', '--json'], '/tmp/repo')
|
||||
|
||||
expect(callMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'linear.commentEdit',
|
||||
{ commentId, body: 'fixed body', workspaceId: undefined },
|
||||
{ timeoutMs: 75_000 }
|
||||
)
|
||||
expect(callMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'linear.commentDelete',
|
||||
{ commentId, workspaceId: 'w' },
|
||||
{ timeoutMs: 75_000 }
|
||||
)
|
||||
expect(vi.mocked(console.log).mock.calls[0][0]).toBe('Edited comment comment-id on ENG-123.')
|
||||
})
|
||||
|
||||
it('rejects malformed write ids before dispatch', async () => {
|
||||
await main(
|
||||
[
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type {
|
||||
LinearAttachRequest,
|
||||
LinearAttachResult,
|
||||
LinearCommentAddRequest,
|
||||
LinearCommentAddResult,
|
||||
LinearCreateRequest,
|
||||
LinearCreateResult,
|
||||
LinearIssueListRequest,
|
||||
@@ -47,7 +45,6 @@ import {
|
||||
} from '../linear-request-builders'
|
||||
import {
|
||||
formatLinearAttach,
|
||||
formatLinearCommentAdd,
|
||||
formatLinearCreate,
|
||||
formatLinearIssue,
|
||||
formatLinearIssueList,
|
||||
@@ -64,6 +61,7 @@ import {
|
||||
printLinearProjectListWarnings,
|
||||
printLinearSearchWarnings
|
||||
} from '../linear-format'
|
||||
import { LINEAR_COMMENT_WRITE_HANDLERS } from './linear-comment-write'
|
||||
import { runLinearListIssues } from './linear-list-issues'
|
||||
import { linearRelationWriteHandler } from './linear-relation-write'
|
||||
import { runLinearSaveIssue } from './linear-save-issue'
|
||||
@@ -76,6 +74,7 @@ export const LINEAR_HANDLERS: Record<string, CommandHandler> = {
|
||||
'linear list-issues': runLinearListIssues,
|
||||
'linear relation add': linearRelationWriteHandler('add'),
|
||||
'linear relation remove': linearRelationWriteHandler('remove'),
|
||||
...LINEAR_COMMENT_WRITE_HANDLERS,
|
||||
'linear issue': async ({ flags, client, cwd, json }) => {
|
||||
const request = buildIssueRequest(flags, cwd, client.isRemote)
|
||||
const response = await client.call<LinearIssueContextResult>('linear.issueContext', request, {
|
||||
@@ -213,19 +212,6 @@ export const LINEAR_HANDLERS: Record<string, CommandHandler> = {
|
||||
'linear label add': async (ctx) => runLabelUpdate(ctx, 'add'),
|
||||
'linear label remove': async (ctx) => runLabelUpdate(ctx, 'remove'),
|
||||
'linear label set': async (ctx) => runLabelUpdate(ctx, 'set'),
|
||||
'linear comment add': async ({ flags, client, cwd, json }) => {
|
||||
const body = await readLinearBody(flags, cwd, { required: true })
|
||||
const request: LinearCommentAddRequest = {
|
||||
...buildWriteTargetRequest(flags, cwd, client.isRemote),
|
||||
body,
|
||||
replyTo: getOptionalStringFlag(flags, 'reply-to'),
|
||||
writeId: getOptionalWriteId(flags)
|
||||
}
|
||||
const response = await client.call<LinearCommentAddResult>('linear.issueAddComment', request, {
|
||||
timeoutMs: LINEAR_WRITE_TIMEOUT_MS
|
||||
})
|
||||
printResult(response, json, formatLinearCommentAdd)
|
||||
},
|
||||
'linear attach': async ({ flags, client, cwd, json }) => {
|
||||
const request: LinearAttachRequest = {
|
||||
...buildWriteTargetRequest(flags, cwd, client.isRemote),
|
||||
|
||||
@@ -91,6 +91,9 @@ function formatCommandFlagHelp(flag: string, commandPath: string[]): string {
|
||||
if (command === 'linear issue' && flag === 'id') {
|
||||
return '--id <id> Linear issue key, id, or URL'
|
||||
}
|
||||
if ((command === 'linear comment edit' || command === 'linear comment delete') && flag === 'id') {
|
||||
return '--id <commentId> Linear comment id (a UUID)'
|
||||
}
|
||||
if (command === 'linear issue' && flag === 'workspace') {
|
||||
return '--workspace <id> Connected Linear workspace id'
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type {
|
||||
LinearAttachResult,
|
||||
LinearCommentAddResult,
|
||||
LinearCommentDeleteResult,
|
||||
LinearCommentEditResult,
|
||||
LinearCreateResult,
|
||||
LinearIssueListResult,
|
||||
LinearMcpIssueListResult,
|
||||
@@ -167,6 +169,14 @@ export function formatLinearCommentAdd(result: LinearCommentAddResult): string {
|
||||
return `Added comment ${result.comment.id} to ${result.issue.identifier}${suffix}.`
|
||||
}
|
||||
|
||||
export function formatLinearCommentEdit(result: LinearCommentEditResult): string {
|
||||
return `Edited comment ${result.comment.id} on ${result.issue.identifier}.`
|
||||
}
|
||||
|
||||
export function formatLinearCommentDelete(result: LinearCommentDeleteResult): string {
|
||||
return `Deleted comment ${result.comment.id} from ${result.issue.identifier}.`
|
||||
}
|
||||
|
||||
export function formatLinearAttach(result: LinearAttachResult): string {
|
||||
const suffix = result.meta.deduplicated ? ' (already attached)' : ''
|
||||
return `Attached ${result.attachment.title} to ${result.issue.identifier}${suffix}.`
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { HandlerGroup } from './handler-group-manifest'
|
||||
|
||||
export const LINEAR_HANDLER_GROUP: HandlerGroup = {
|
||||
name: 'linear',
|
||||
keys: [
|
||||
'linear save-issue',
|
||||
'linear list-issues',
|
||||
'linear relation add',
|
||||
'linear relation remove',
|
||||
'linear issue',
|
||||
'linear search',
|
||||
'linear team list',
|
||||
'linear team members',
|
||||
'linear team states',
|
||||
'linear team labels',
|
||||
'linear project list',
|
||||
'linear list',
|
||||
'linear status set',
|
||||
'linear assignee set',
|
||||
'linear assignee clear',
|
||||
'linear priority set',
|
||||
'linear priority clear',
|
||||
'linear estimate set',
|
||||
'linear estimate clear',
|
||||
'linear due-date set',
|
||||
'linear due-date clear',
|
||||
'linear label add',
|
||||
'linear label remove',
|
||||
'linear label set',
|
||||
'linear comment add',
|
||||
'linear comment edit',
|
||||
'linear comment delete',
|
||||
'linear attach',
|
||||
'linear create'
|
||||
],
|
||||
load: async () => (await import('./handlers/linear.js')).LINEAR_HANDLERS
|
||||
}
|
||||
@@ -211,6 +211,35 @@ export const LINEAR_COMMAND_SPECS: CommandSpec[] = [
|
||||
],
|
||||
notes: ['Use --body-file - to read multiline comment bodies from stdin.']
|
||||
},
|
||||
{
|
||||
path: ['linear', 'comment', 'edit'],
|
||||
summary: 'Replace the body of a Linear comment',
|
||||
usage:
|
||||
'orca linear comment edit <commentId> (--body <text> | --body-file <path|->) [--workspace <id>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'body', 'body-file', 'workspace', 'id'],
|
||||
positionalArgs: ['id'],
|
||||
examples: [
|
||||
'orca linear comment edit <commentId> --body "Updated: ready for review."',
|
||||
'orca linear comment edit <commentId> --body-file - --json'
|
||||
],
|
||||
notes: [
|
||||
'Comment ids come from `comment add` output or `orca linear issue <id> --comments --json`.',
|
||||
'Linear only lets a comment be edited by its author.'
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ['linear', 'comment', 'delete'],
|
||||
destructive: true,
|
||||
aliases: [['linear', 'comment', 'rm']],
|
||||
summary: 'Delete a Linear comment',
|
||||
usage: 'orca linear comment delete <commentId> [--workspace <id>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'workspace', 'id'],
|
||||
positionalArgs: ['id'],
|
||||
examples: ['orca linear comment delete <commentId> --json'],
|
||||
notes: [
|
||||
'Comment ids come from `comment add` output or `orca linear issue <id> --comments --json`.'
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ['linear', 'attach'],
|
||||
summary: 'Attach a link to a Linear issue',
|
||||
|
||||
@@ -123,8 +123,11 @@ export async function resolveIssue(
|
||||
|
||||
export const getConnectedWorkspaces = (): LinearWorkspace[] => getStatus().workspaces ?? []
|
||||
|
||||
export function getRequiredEntry(workspaceId: string): LinearClientForWorkspace {
|
||||
const entry = getClients(workspaceId)[0]
|
||||
export function getRequiredEntry(
|
||||
workspaceId: string,
|
||||
signal?: AbortSignal
|
||||
): LinearClientForWorkspace {
|
||||
const entry = getClients(workspaceId, signal)[0]
|
||||
if (!entry) {
|
||||
throw linearError('linear_not_connected', 'Linear is not connected.')
|
||||
}
|
||||
|
||||
@@ -104,6 +104,60 @@ export async function addIssueCommentForAgent(
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateIssueCommentForAgent(
|
||||
commentId: string,
|
||||
body: string,
|
||||
workspaceId: string,
|
||||
options: { signal?: AbortSignal; beforeInvoke?: () => void } = {}
|
||||
): Promise<LinearCommentWriteRecord> {
|
||||
const entry = getClients(workspaceId)[0]
|
||||
if (!entry) {
|
||||
throw new LinearWriteFailure('failed', 'Not connected to Linear')
|
||||
}
|
||||
|
||||
return runLinearWrite(
|
||||
entry,
|
||||
options.signal,
|
||||
async (client) => {
|
||||
const result = await client.updateComment(commentId, { body })
|
||||
if (!result.success) {
|
||||
throw new LinearWriteFailure('failed', 'Failed to edit comment')
|
||||
}
|
||||
const record = await confirmLinearWrite('Comment was edited but could not be retrieved', () =>
|
||||
readCommentWriteRecord(client, commentId)
|
||||
)
|
||||
if (!record) {
|
||||
throw new LinearWriteFailure('unconfirmed', 'Comment was edited but could not be retrieved')
|
||||
}
|
||||
return record
|
||||
},
|
||||
options.beforeInvoke
|
||||
)
|
||||
}
|
||||
|
||||
export async function deleteIssueCommentForAgent(
|
||||
commentId: string,
|
||||
workspaceId: string,
|
||||
options: { signal?: AbortSignal; beforeInvoke?: () => void } = {}
|
||||
): Promise<void> {
|
||||
const entry = getClients(workspaceId)[0]
|
||||
if (!entry) {
|
||||
throw new LinearWriteFailure('failed', 'Not connected to Linear')
|
||||
}
|
||||
|
||||
await runLinearWrite(
|
||||
entry,
|
||||
options.signal,
|
||||
async (client) => {
|
||||
const result = await client.deleteComment(commentId)
|
||||
if (!result.success) {
|
||||
throw new LinearWriteFailure('failed', 'Failed to delete comment')
|
||||
}
|
||||
},
|
||||
options.beforeInvoke
|
||||
)
|
||||
}
|
||||
|
||||
export async function createIssueAttachment(
|
||||
issueId: string,
|
||||
input: { id: string; title: string; url: string },
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { LinearWorkspaceSelection } from '../../shared/linear/workspace-typ
|
||||
import { acquire, release } from './linear-request-concurrency'
|
||||
import { clearToken } from './linear-token-store'
|
||||
import { getClients, isAuthError } from './client'
|
||||
import { getRequiredEntry } from './issue-context-client'
|
||||
import { getDescriptionImageUrls } from './linear-description-images'
|
||||
import {
|
||||
ATTACHMENT_BY_UUID_QUERY,
|
||||
@@ -113,21 +114,27 @@ export async function getIssueByUuidForAgent(
|
||||
|
||||
export async function getCommentByUuidForAgent(
|
||||
id: string,
|
||||
workspaceId?: string | null
|
||||
workspaceId?: string | null,
|
||||
signal?: AbortSignal
|
||||
): Promise<LinearCommentWriteRecord | null> {
|
||||
const entry = getClients(workspaceId)[0]
|
||||
const entry =
|
||||
signal && workspaceId ? getRequiredEntry(workspaceId, signal) : getClients(workspaceId)[0]
|
||||
if (!entry) {
|
||||
return null
|
||||
}
|
||||
|
||||
return runLinearLookup(entry, async () => {
|
||||
const result = await entry.client.client.rawRequest<
|
||||
LinearCommentByUuidResponse,
|
||||
LinearRawVariables
|
||||
>(COMMENT_BY_UUID_QUERY, { id })
|
||||
const comment = result.data?.comment
|
||||
return comment ? mapRawCommentWriteRecord(comment) : null
|
||||
})
|
||||
return runLinearLookup(
|
||||
entry,
|
||||
async () => {
|
||||
const result = await entry.client.client.rawRequest<
|
||||
LinearCommentByUuidResponse,
|
||||
LinearRawVariables
|
||||
>(COMMENT_BY_UUID_QUERY, { id })
|
||||
const comment = result.data?.comment
|
||||
return comment ? mapRawCommentWriteRecord(comment) : null
|
||||
},
|
||||
signal
|
||||
)
|
||||
}
|
||||
|
||||
export async function getAttachmentByUuidForAgent(
|
||||
|
||||
@@ -117,13 +117,16 @@ export function classifyLinearWriteFailure(error: unknown): LinearWriteFailure {
|
||||
export async function runLinearWrite<T>(
|
||||
entry: LinearClientForWorkspace,
|
||||
signal: AbortSignal | undefined,
|
||||
write: (client: LinearClient) => Promise<T>
|
||||
write: (client: LinearClient) => Promise<T>,
|
||||
beforeInvoke?: () => void
|
||||
): Promise<T> {
|
||||
await acquire()
|
||||
await acquire(signal)
|
||||
try {
|
||||
signal?.throwIfAborted()
|
||||
const client = signal
|
||||
? new (loadLinearSdk().LinearClient)({ apiKey: entry.apiKey, signal })
|
||||
: entry.client
|
||||
beforeInvoke?.()
|
||||
return await write(client)
|
||||
} catch (error) {
|
||||
if (error instanceof LinearWriteFailure) {
|
||||
@@ -141,10 +144,12 @@ export async function runLinearWrite<T>(
|
||||
|
||||
export async function runLinearLookup<T>(
|
||||
entry: LinearClientForWorkspace,
|
||||
lookup: () => Promise<T>
|
||||
lookup: () => Promise<T>,
|
||||
signal?: AbortSignal
|
||||
): Promise<T | null> {
|
||||
await acquire()
|
||||
await acquire(signal)
|
||||
try {
|
||||
signal?.throwIfAborted()
|
||||
return await lookup()
|
||||
} catch (error) {
|
||||
if (isAuthError(error)) {
|
||||
|
||||
@@ -4,6 +4,8 @@ import { isLinearUuid } from '../../../../shared/linear/uuid'
|
||||
import {
|
||||
AgentIssueContext,
|
||||
AgentSearchIssues,
|
||||
LinearCommentDelete,
|
||||
LinearCommentEdit,
|
||||
LinearCurrentContext,
|
||||
LinearIssueAddComment,
|
||||
LinearIssueAttachLink,
|
||||
@@ -120,6 +122,18 @@ export const LINEAR_AGENT_ACCESS_METHODS = [
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.linearIssueAddComment({ ...params, writeId: parseLinearWriteId(params.writeId) })
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'linear.commentEdit',
|
||||
permission: 'workspace',
|
||||
params: LinearCommentEdit,
|
||||
handler: async (params, { runtime, signal }) => runtime.linearCommentEdit(params, signal)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'linear.commentDelete',
|
||||
permission: 'workspace',
|
||||
params: LinearCommentDelete,
|
||||
handler: async (params, { runtime, signal }) => runtime.linearCommentDelete(params, signal)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'linear.issueAttachLink',
|
||||
permission: 'workspace',
|
||||
|
||||
@@ -8,6 +8,8 @@ export type {
|
||||
LinearCurrentIssueContextHints,
|
||||
LinearAttachResult,
|
||||
LinearCommentAddResult,
|
||||
LinearCommentDeleteResult,
|
||||
LinearCommentEditResult,
|
||||
LinearCreateResult,
|
||||
LinearErrorCode,
|
||||
LinearIssueListFilter,
|
||||
@@ -51,7 +53,9 @@ export {
|
||||
export {
|
||||
addIssueComment as addLinearIssueComment,
|
||||
addIssueCommentForAgent as addLinearIssueCommentForAgent,
|
||||
createIssueAttachment as createLinearIssueAttachment
|
||||
createIssueAttachment as createLinearIssueAttachment,
|
||||
deleteIssueCommentForAgent as deleteLinearIssueCommentForAgent,
|
||||
updateIssueCommentForAgent as updateLinearIssueCommentForAgent
|
||||
} from '../linear/linear-issue-comments'
|
||||
export {
|
||||
createIssueForAgent as createLinearIssueForAgent,
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason'
|
||||
import { linearError, type LinearAgentAccessError } from '../linear/issue-context-errors'
|
||||
import { LinearWriteFailure } from '../linear/linear-issue-write-support'
|
||||
|
||||
export async function runLinearCommentAction<Owner, Result>(
|
||||
resolveOwner: (signal: AbortSignal) => Promise<Owner>,
|
||||
write: (
|
||||
owner: Owner,
|
||||
options: { signal: AbortSignal; beforeInvoke: () => void }
|
||||
) => Promise<Result>,
|
||||
unconfirmed: (owner: Owner) => LinearAgentAccessError,
|
||||
mapFailure: (error: unknown) => LinearAgentAccessError,
|
||||
cancellation?: AbortSignal
|
||||
): 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
|
||||
const timeout = (): LinearAgentAccessError =>
|
||||
linearError(
|
||||
'linear_timeout',
|
||||
'The Linear comment action stopped before a change was attempted.'
|
||||
)
|
||||
const totalTimer = setTimeout(() => action.abort(timeout()), 55_000)
|
||||
const discovery = new AbortController()
|
||||
let discoveryExpired = false
|
||||
const discoveryTimer = setTimeout(() => {
|
||||
discoveryExpired = true
|
||||
discovery.abort(timeout())
|
||||
}, 30_000)
|
||||
let writeTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let owner: Owner | undefined
|
||||
let invoked = false
|
||||
try {
|
||||
const discoverySignal = AbortSignal.any([signal, discovery.signal])
|
||||
try {
|
||||
discoverySignal.throwIfAborted()
|
||||
owner = await waitForPromiseWithSignal(resolveOwner(discoverySignal), discoverySignal)
|
||||
if (Date.now() >= discoveryDeadline) {
|
||||
discoveryExpired = true
|
||||
throw timeout()
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(discoveryTimer)
|
||||
discovery.abort()
|
||||
}
|
||||
const writeDeadline = Math.min(deadline, Date.now() + 25_000)
|
||||
writeTimer = setTimeout(() => action.abort(timeout()), Math.max(0, writeDeadline - Date.now()))
|
||||
const beforeInvoke = (): void => {
|
||||
signal.throwIfAborted()
|
||||
if (Date.now() >= writeDeadline) {
|
||||
action.abort(timeout())
|
||||
signal.throwIfAborted()
|
||||
}
|
||||
invoked = true
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
const result = await waitForPromiseWithSignal(write(owner, { signal, beforeInvoke }), signal)
|
||||
return { owner, result }
|
||||
} catch (error) {
|
||||
if (
|
||||
invoked &&
|
||||
owner !== undefined &&
|
||||
(signal.aborted || (error instanceof LinearWriteFailure && error.kind === 'unconfirmed'))
|
||||
) {
|
||||
throw unconfirmed(owner)
|
||||
}
|
||||
if (signal.aborted || discoveryExpired) {
|
||||
throw timeout()
|
||||
}
|
||||
if (error instanceof LinearWriteFailure) {
|
||||
throw linearError(
|
||||
error.kind === 'network' ? 'linear_network_error' : 'linear_write_failed',
|
||||
'Linear could not complete the comment change. Retry after checking the comment.'
|
||||
)
|
||||
}
|
||||
throw mapFailure(error)
|
||||
} finally {
|
||||
clearTimeout(totalTimer)
|
||||
clearTimeout(discoveryTimer)
|
||||
if (writeTimer !== undefined) {
|
||||
clearTimeout(writeTimer)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,25 @@ import {
|
||||
LINEAR_WRITE_BODY_CAP,
|
||||
addLinearIssueCommentForAgent,
|
||||
createLinearIssueAttachment,
|
||||
deleteLinearIssueCommentForAgent,
|
||||
getLinearCommentByUuidForAgent,
|
||||
isLinearUuid,
|
||||
updateLinearIssueCommentForAgent,
|
||||
LinearWriteFailure,
|
||||
linearError
|
||||
} from './runtime-linear-command-dependencies'
|
||||
import type {
|
||||
LinearAgentAccessError,
|
||||
LinearCurrentIssueContextHints,
|
||||
LinearAttachResult,
|
||||
LinearCommentAddResult
|
||||
LinearCommentAddResult,
|
||||
LinearCommentDeleteResult,
|
||||
LinearCommentEditResult
|
||||
} from './runtime-linear-command-dependencies'
|
||||
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: {
|
||||
@@ -145,4 +155,140 @@ export class RuntimeLinearCommentCommands extends RuntimeLinearCreateCommands {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async linearCommentEdit(
|
||||
params: {
|
||||
commentId: string
|
||||
body: string
|
||||
workspaceId?: string
|
||||
},
|
||||
signal?: AbortSignal
|
||||
): Promise<LinearCommentEditResult> {
|
||||
if (params.body.length > LINEAR_WRITE_BODY_CAP) {
|
||||
throw linearError('linear_body_too_large', 'Linear comment body is too large.')
|
||||
}
|
||||
const {
|
||||
owner: { comment, workspaceId },
|
||||
result: edited
|
||||
} = await runLinearCommentAction(
|
||||
(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
|
||||
)
|
||||
await this.notifyLinearLinkedIssueUpdated(workspaceId, comment.issue.identifier)
|
||||
return {
|
||||
comment: { id: edited.id, url: edited.url, parentId: edited.parentId },
|
||||
issue: this.linearWriteIssueRef(comment.issue),
|
||||
meta: { workspaceId, bodyChars: params.body.length }
|
||||
}
|
||||
}
|
||||
|
||||
async linearCommentDelete(
|
||||
params: {
|
||||
commentId: string
|
||||
workspaceId?: string
|
||||
},
|
||||
signal?: AbortSignal
|
||||
): Promise<LinearCommentDeleteResult> {
|
||||
const {
|
||||
owner: { comment, workspaceId }
|
||||
} = await runLinearCommentAction(
|
||||
(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
|
||||
)
|
||||
await this.notifyLinearLinkedIssueUpdated(workspaceId, comment.issue.identifier)
|
||||
return {
|
||||
comment: { id: comment.id, parentId: comment.parentId },
|
||||
issue: this.linearWriteIssueRef(comment.issue),
|
||||
meta: { workspaceId }
|
||||
}
|
||||
}
|
||||
|
||||
// A UUID has one owner; unrelated unavailable workspaces must not delay its mutation.
|
||||
public async resolveLinearAgentComment(
|
||||
params: {
|
||||
commentId: string
|
||||
workspaceId?: string
|
||||
},
|
||||
signal: AbortSignal
|
||||
): Promise<{ comment: LinearAgentComment; workspaceId: string }> {
|
||||
if (!isLinearUuid(params.commentId)) {
|
||||
throw this.linearCommentNotFound(params.commentId)
|
||||
}
|
||||
const workspaces = getConnectedWorkspaces()
|
||||
if (workspaces.length === 0) {
|
||||
throw linearError('linear_not_connected', 'Linear is not connected.')
|
||||
}
|
||||
const selected = resolveWorkspaceSelector(params, workspaces)
|
||||
const missing = this.linearCommentNotFound(params.commentId)
|
||||
try {
|
||||
return await Promise.any(
|
||||
(selected ? [selected] : workspaces).map(async (workspace) => {
|
||||
const comment = await getLinearCommentByUuidForAgent(
|
||||
params.commentId,
|
||||
workspace.id,
|
||||
signal
|
||||
)
|
||||
if (!comment) {
|
||||
throw missing
|
||||
}
|
||||
return { comment, workspaceId: workspace.id }
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
if (!(error instanceof AggregateError)) {
|
||||
throw error
|
||||
}
|
||||
const failures: unknown[] = error.errors.filter((reason: unknown) => reason !== missing)
|
||||
if (failures.length > 0) {
|
||||
throw this.mapLinearReadFailure(failures[0])
|
||||
}
|
||||
throw missing
|
||||
}
|
||||
}
|
||||
|
||||
public linearCommentNotFound(commentId: string): LinearAgentAccessError {
|
||||
return linearError(
|
||||
'linear_comment_not_found',
|
||||
`No Linear issue comment ${commentId} was found.`,
|
||||
{
|
||||
nextSteps: [
|
||||
'Run `orca linear issue <id> --comments --json` and pass a comment `id` from it.'
|
||||
]
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
public linearCommentChangeUnconfirmed(
|
||||
verb: 'edit' | 'delete',
|
||||
comment: LinearAgentComment,
|
||||
workspaceId: string,
|
||||
cause?: string
|
||||
): LinearAgentAccessError {
|
||||
const issue = comment.issue.identifier
|
||||
const readBack = `orca linear issue ${issue} --comments --workspace ${workspaceId} --json`
|
||||
return linearError(
|
||||
'linear_write_unconfirmed',
|
||||
`Linear may have applied the comment ${verb}, but Orca could not confirm it.`,
|
||||
{
|
||||
workspaceId,
|
||||
issueIdentifier: comment.issue.identifier,
|
||||
nextSteps: [
|
||||
`Run \`${readBack}\` and rerun the ${verb} only if comment ${comment.id} ${
|
||||
verb === 'edit' ? 'still has its old body' : 'is still listed'
|
||||
}.`
|
||||
],
|
||||
...(cause ? { cause } : {})
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type LinearAgentComment = NonNullable<Awaited<ReturnType<typeof getLinearCommentByUuidForAgent>>>
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as LinearClientModule from '../linear/client'
|
||||
import type { ResolvedWorktree } from './runtime-worktree-path-identity'
|
||||
|
||||
const COMMENT_ID = '9f3c2a10-1111-4222-8333-444455556666'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getClients: vi.fn(),
|
||||
getStatus: vi.fn(),
|
||||
lookup: vi.fn(),
|
||||
updateComment: vi.fn(),
|
||||
createComment: vi.fn(),
|
||||
deleteComment: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../linear/client', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof LinearClientModule>()),
|
||||
getClients: mocks.getClients,
|
||||
getStatus: mocks.getStatus
|
||||
}))
|
||||
|
||||
vi.mock('../linear/linear-sdk', () => ({
|
||||
loadLinearSdk: () => ({
|
||||
// Why: writes build a fresh SDK client per call so the deadline signal can abort it.
|
||||
LinearClient: class {
|
||||
client = {
|
||||
rawRequest: (_query: string, variables: { id: string }) => mocks.lookup('w2', variables)
|
||||
}
|
||||
updateComment = mocks.updateComment
|
||||
createComment = mocks.createComment
|
||||
deleteComment = mocks.deleteComment
|
||||
},
|
||||
AuthenticationLinearError: class extends Error {}
|
||||
})
|
||||
}))
|
||||
|
||||
import { RuntimeLinearReadCommands } from './runtime-linear-read-commands'
|
||||
import { acquire, release } from '../linear/linear-request-concurrency'
|
||||
import { addIssueCommentForAgent } from '../linear/linear-issue-comments'
|
||||
import { linearError } from '../linear/issue-context-errors'
|
||||
|
||||
function rawComment(body: string): unknown {
|
||||
return {
|
||||
data: {
|
||||
comment: {
|
||||
id: COMMENT_ID,
|
||||
url: 'https://linear.app/acme/issue/ENG-1#comment-1',
|
||||
body,
|
||||
parent: null,
|
||||
issue: { id: 'issue-1', identifier: 'ENG-1', url: 'https://linear.app/acme/issue/ENG-1' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createCommands(emitClientEvent = vi.fn()): RuntimeLinearReadCommands {
|
||||
const worktree: ResolvedWorktree = {
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
path: '/tmp/pr-30003/worktree',
|
||||
head: 'head',
|
||||
branch: 'main',
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
displayName: '',
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: 'ENG-1',
|
||||
linkedLinearIssueWorkspaceId: 'w2',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0,
|
||||
parentWorktreeId: null,
|
||||
childWorktreeIds: [],
|
||||
lineage: null,
|
||||
git: {
|
||||
path: '/tmp/pr-30003/worktree',
|
||||
head: 'head',
|
||||
branch: 'main',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
}
|
||||
return new RuntimeLinearReadCommands({
|
||||
runtimeAvailable: () => true,
|
||||
showTerminal: async () => {
|
||||
throw new Error('unused')
|
||||
},
|
||||
resolveWorktreeSelector: async () => {
|
||||
throw new Error('unused')
|
||||
},
|
||||
listResolvedWorktrees: async () => [worktree],
|
||||
setWorktreeMeta: () => {},
|
||||
emitClientEvent
|
||||
})
|
||||
}
|
||||
|
||||
describe('Linear comment edit and delete', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.getStatus.mockReturnValue({ workspaces: [{ id: 'w1' }, { id: 'w2' }] })
|
||||
mocks.getClients.mockImplementation((workspaceId: string, signal?: AbortSignal) => [
|
||||
{
|
||||
workspace: { id: workspaceId },
|
||||
apiKey: `key-${workspaceId}`,
|
||||
client: {
|
||||
client: {
|
||||
rawRequest: (_query: string, variables: { id: string }) =>
|
||||
mocks.lookup(workspaceId, variables, signal)
|
||||
}
|
||||
}
|
||||
}
|
||||
])
|
||||
})
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it.each([
|
||||
{ workspaces: [], workspaceId: undefined, missingToken: false, code: 'linear_not_connected' },
|
||||
{
|
||||
workspaces: [{ id: 'w2' }],
|
||||
workspaceId: 'typo',
|
||||
missingToken: false,
|
||||
code: 'linear_invalid_workspace'
|
||||
},
|
||||
{
|
||||
workspaces: [{ id: 'w2' }],
|
||||
workspaceId: undefined,
|
||||
missingToken: true,
|
||||
code: 'linear_not_connected'
|
||||
},
|
||||
{
|
||||
workspaces: [{ id: 'w1' }, { id: 'w2' }],
|
||||
workspaceId: undefined,
|
||||
missingToken: true,
|
||||
code: 'linear_not_connected'
|
||||
},
|
||||
{
|
||||
workspaces: [{ id: 'w1' }],
|
||||
workspaceId: undefined,
|
||||
missingToken: false,
|
||||
code: 'linear_comment_not_found'
|
||||
}
|
||||
])(
|
||||
'distinguishes inspected absence from unavailable access: $code',
|
||||
async ({ workspaces, workspaceId, missingToken, code }) => {
|
||||
mocks.getStatus.mockReturnValue({ workspaces })
|
||||
if (missingToken) {
|
||||
const clients = mocks.getClients.getMockImplementation()
|
||||
mocks.getClients.mockImplementation((id: string, signal?: AbortSignal) =>
|
||||
id === 'w2' ? [] : clients?.(id, signal)
|
||||
)
|
||||
}
|
||||
mocks.lookup.mockResolvedValue({ data: { comment: null } })
|
||||
await expect(
|
||||
createCommands().linearCommentDelete({ commentId: COMMENT_ID, workspaceId })
|
||||
).rejects.toMatchObject({ code })
|
||||
expect(mocks.deleteComment).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['stalled', 'failed'])(
|
||||
'uses the first owner despite a %s unrelated lookup',
|
||||
async (state) => {
|
||||
let losingSignal: AbortSignal | undefined
|
||||
let finish: () => void = () => {}
|
||||
mocks.lookup.mockImplementation((id: string, _variables: unknown, signal?: AbortSignal) => {
|
||||
if (id === 'w2') {
|
||||
return rawComment('body')
|
||||
}
|
||||
if (state === 'failed') {
|
||||
throw new Error('Network unavailable')
|
||||
}
|
||||
losingSignal = signal
|
||||
return new Promise((resolve) => {
|
||||
finish = () => resolve({ data: { comment: null } })
|
||||
})
|
||||
})
|
||||
mocks.deleteComment.mockResolvedValue({ success: true })
|
||||
try {
|
||||
await expect(
|
||||
createCommands().linearCommentDelete({ commentId: COMMENT_ID })
|
||||
).resolves.toMatchObject({ meta: { workspaceId: 'w2' } })
|
||||
if (state === 'stalled') {
|
||||
expect(losingSignal?.aborted).toBe(true)
|
||||
}
|
||||
expect(mocks.deleteComment).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['disconnect', 'discovery deadline', 'queued write deadline', 'absolute deadline'])(
|
||||
'never starts a later mutation after %s',
|
||||
async (phase) => {
|
||||
vi.useFakeTimers()
|
||||
const cancellation = new AbortController()
|
||||
const clients = mocks.getClients.getMockImplementation()
|
||||
let held = 0
|
||||
mocks.getClients.mockImplementation((id: string, signal?: AbortSignal) => {
|
||||
if (!signal && (phase === 'queued write deadline' || phase === 'absolute deadline')) {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
void acquire()
|
||||
held++
|
||||
}
|
||||
}
|
||||
return clients?.(id, signal)
|
||||
})
|
||||
let finish: () => void = () => {}
|
||||
mocks.lookup.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finish = () => resolve(rawComment('body'))
|
||||
})
|
||||
)
|
||||
const pending = createCommands().linearCommentDelete(
|
||||
{ commentId: COMMENT_ID, workspaceId: 'w2' },
|
||||
cancellation.signal
|
||||
)
|
||||
const rejected = expect(pending).rejects.toMatchObject({ code: 'linear_timeout' })
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
if (phase === 'disconnect') {
|
||||
cancellation.abort()
|
||||
} else if (phase === 'discovery deadline') {
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
} else {
|
||||
finish()
|
||||
if (phase === 'absolute deadline') {
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
vi.setSystemTime(Date.now() + 55_000)
|
||||
for (; held > 0; held--) {
|
||||
release()
|
||||
}
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(phase === 'queued write deadline' ? 25_000 : 0)
|
||||
}
|
||||
await rejected
|
||||
finish()
|
||||
for (; held > 0; held--) {
|
||||
release()
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(mocks.deleteComment).not.toHaveBeenCalled()
|
||||
await Promise.all([acquire(), acquire(), acquire(), acquire()])
|
||||
for (let i = 0; i < 4; i++) {
|
||||
release()
|
||||
}
|
||||
} finally {
|
||||
finish()
|
||||
for (; held > 0; held--) {
|
||||
release()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps add uncertainty 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 }),
|
||||
() => linearError('linear_write_unconfirmed', 'Add was not confirmed.')
|
||||
)
|
||||
const rejected = expect(pending).rejects.toMatchObject({ code: 'linear_write_unconfirmed' })
|
||||
await vi.advanceTimersByTimeAsync(25_000)
|
||||
await rejected
|
||||
expect(mocks.createComment).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
release()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('pins uncertainty to the owner only after invoking the provider', async () => {
|
||||
vi.useFakeTimers()
|
||||
mocks.lookup.mockResolvedValue(rawComment('body'))
|
||||
let finish: () => void = () => {}
|
||||
mocks.deleteComment.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finish = () => resolve({ success: true })
|
||||
})
|
||||
)
|
||||
const pending = createCommands().linearCommentDelete({
|
||||
commentId: COMMENT_ID,
|
||||
workspaceId: 'w2'
|
||||
})
|
||||
const rejected = expect(pending).rejects.toMatchObject({
|
||||
code: 'linear_write_unconfirmed',
|
||||
data: { workspaceId: 'w2', issueIdentifier: 'ENG-1' }
|
||||
})
|
||||
try {
|
||||
await vi.advanceTimersByTimeAsync(25_000)
|
||||
await rejected
|
||||
expect(mocks.deleteComment).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
finish()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('reports a failed edit readback as unconfirmed in its owning workspace', async () => {
|
||||
mocks.lookup.mockImplementation(() => {
|
||||
if (mocks.updateComment.mock.calls.length > 0) {
|
||||
throw new Error('Network unavailable')
|
||||
}
|
||||
return rawComment('body')
|
||||
})
|
||||
mocks.updateComment.mockResolvedValue({ success: true })
|
||||
await expect(
|
||||
createCommands().linearCommentEdit({
|
||||
commentId: COMMENT_ID,
|
||||
body: 'edited',
|
||||
workspaceId: 'w2'
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'linear_write_unconfirmed',
|
||||
data: { workspaceId: 'w2', issueIdentifier: 'ENG-1' }
|
||||
})
|
||||
expect(mocks.updateComment).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('edits a comment in the workspace that owns it and refreshes its worktree', async () => {
|
||||
mocks.lookup.mockImplementation(async (workspaceId: string) => {
|
||||
if (workspaceId === 'w1') {
|
||||
throw new Error('Entity not found: Comment - Could not find referenced Comment.')
|
||||
}
|
||||
return rawComment(mocks.updateComment.mock.calls.length > 0 ? 'new body' : 'old body')
|
||||
})
|
||||
mocks.updateComment.mockResolvedValue({ success: true })
|
||||
const emitClientEvent = vi.fn()
|
||||
|
||||
const result = await createCommands(emitClientEvent).linearCommentEdit({
|
||||
commentId: COMMENT_ID,
|
||||
body: 'new body'
|
||||
})
|
||||
|
||||
expect(mocks.updateComment).toHaveBeenCalledWith(COMMENT_ID, { body: 'new body' })
|
||||
expect(result).toEqual({
|
||||
comment: {
|
||||
id: COMMENT_ID,
|
||||
url: 'https://linear.app/acme/issue/ENG-1#comment-1',
|
||||
parentId: null
|
||||
},
|
||||
issue: { id: 'issue-1', identifier: 'ENG-1', url: 'https://linear.app/acme/issue/ENG-1' },
|
||||
meta: { workspaceId: 'w2', bodyChars: 8 }
|
||||
})
|
||||
expect(emitClientEvent).toHaveBeenCalledWith({
|
||||
type: 'linearLinkedIssueUpdated',
|
||||
worktreeId: 'wt-1',
|
||||
identifier: 'ENG-1',
|
||||
workspaceId: 'w2'
|
||||
})
|
||||
})
|
||||
|
||||
it('deletes a found comment and refuses an id no workspace knows', async () => {
|
||||
mocks.lookup.mockResolvedValue(rawComment('body'))
|
||||
mocks.deleteComment.mockResolvedValue({ success: true })
|
||||
|
||||
await expect(
|
||||
createCommands().linearCommentDelete({ commentId: COMMENT_ID, workspaceId: 'w2' })
|
||||
).resolves.toMatchObject({ comment: { id: COMMENT_ID }, meta: { workspaceId: 'w2' } })
|
||||
expect(mocks.deleteComment).toHaveBeenCalledWith(COMMENT_ID)
|
||||
|
||||
mocks.deleteComment.mockClear()
|
||||
mocks.lookup.mockResolvedValue({ data: { comment: null } })
|
||||
await expect(
|
||||
createCommands().linearCommentDelete({ commentId: COMMENT_ID })
|
||||
).rejects.toMatchObject({ code: 'linear_comment_not_found' })
|
||||
expect(mocks.deleteComment).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import './rpc/unused-default-rpc-methods.test-fixture'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import { defineMethod } from './rpc/core'
|
||||
import { OrcaRuntimeRpcServer } from './runtime-rpc'
|
||||
|
||||
it.each(['linear.commentEdit', 'linear.commentDelete'] as const)(
|
||||
'forwards local disconnect to %s without long-poll keepalives',
|
||||
async (method) => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'pr-30003-cancellation-'))
|
||||
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 })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -37,7 +37,12 @@ export class RuntimeRpcRequestAdmission extends RuntimeRpcBinaryRouting {
|
||||
|
||||
try {
|
||||
return await this.dispatcher.dispatch(request, {
|
||||
signal: longPoll ? context?.signal : undefined,
|
||||
signal:
|
||||
longPoll ||
|
||||
request.method === 'linear.commentEdit' ||
|
||||
request.method === 'linear.commentDelete'
|
||||
? context?.signal
|
||||
: undefined,
|
||||
callerScope
|
||||
})
|
||||
} finally {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { LinearWriteIssueRef } from './agent-result-types'
|
||||
|
||||
export const LINEAR_SEARCH_DEFAULT_LIMIT = 20
|
||||
export const LINEAR_SEARCH_MAX_LIMIT = 50
|
||||
export const LINEAR_COMMENTS_CAP = 500
|
||||
@@ -22,6 +24,7 @@ export const LINEAR_ERROR_CODES = [
|
||||
'linear_invalid_assignee',
|
||||
'linear_invalid_label',
|
||||
'linear_invalid_parent',
|
||||
'linear_comment_not_found',
|
||||
'linear_invalid_project',
|
||||
'linear_team_required',
|
||||
'linear_invalid_url',
|
||||
@@ -145,6 +148,30 @@ export type LinearCommentAddRequest = LinearWriteTargetRequest & {
|
||||
writeId?: string
|
||||
}
|
||||
|
||||
// Why: comment ids are workspace-unique UUIDs, so the comment alone names its issue.
|
||||
export type LinearCommentEditRequest = {
|
||||
commentId: string
|
||||
body: string
|
||||
workspaceId?: string
|
||||
}
|
||||
|
||||
export type LinearCommentDeleteRequest = {
|
||||
commentId: string
|
||||
workspaceId?: string
|
||||
}
|
||||
|
||||
export type LinearCommentEditResult = {
|
||||
comment: { id: string; url: string | null; parentId: string | null }
|
||||
issue: LinearWriteIssueRef
|
||||
meta: { workspaceId: string; bodyChars: number }
|
||||
}
|
||||
|
||||
export type LinearCommentDeleteResult = {
|
||||
comment: { id: string; parentId: string | null }
|
||||
issue: LinearWriteIssueRef
|
||||
meta: { workspaceId: string }
|
||||
}
|
||||
|
||||
export type LinearAttachRequest = LinearWriteTargetRequest & {
|
||||
url: string
|
||||
title?: string
|
||||
|
||||
@@ -97,6 +97,21 @@ export const LinearIssueAddComment = LinearWriteTarget.extend({
|
||||
writeId: OptionalString
|
||||
})
|
||||
|
||||
const LinearCommentWorkspace = OptionalString.refine((value) => value !== 'all', {
|
||||
message: '--workspace all is not valid for Linear writes'
|
||||
})
|
||||
|
||||
export const LinearCommentEdit = z.object({
|
||||
commentId: requiredString('Missing comment id'),
|
||||
body: requiredString('Missing comment body'),
|
||||
workspaceId: LinearCommentWorkspace
|
||||
})
|
||||
|
||||
export const LinearCommentDelete = z.object({
|
||||
commentId: requiredString('Missing comment id'),
|
||||
workspaceId: LinearCommentWorkspace
|
||||
})
|
||||
|
||||
export const LinearIssueRelationWrite = LinearWriteTarget.extend({
|
||||
relatedInput: requiredString('Missing related issue'),
|
||||
relationship: z.enum(['blocks', 'blockedBy', 'relatedTo', 'duplicateOf']),
|
||||
|
||||
@@ -345,6 +345,8 @@ import { JiraSavedFilterMutationParam } from './jira-saved-filter-params'
|
||||
import {
|
||||
AgentIssueContext,
|
||||
AgentSearchIssues,
|
||||
LinearCommentDelete,
|
||||
LinearCommentEdit,
|
||||
LinearCurrentContext,
|
||||
LinearIssueAddComment,
|
||||
LinearIssueAttachLink,
|
||||
@@ -1056,6 +1058,8 @@ export const RPC_PARAMS_BY_METHOD = {
|
||||
'linear.agentTeamList': LinearWorkspaceRead,
|
||||
'linear.agentTeamMembers': LinearTeamLookup,
|
||||
'linear.agentTeamStates': LinearTeamLookup,
|
||||
'linear.commentDelete': LinearCommentDelete,
|
||||
'linear.commentEdit': LinearCommentEdit,
|
||||
'linear.connect': ConnectOfLinearParams,
|
||||
'linear.createIssue': CreateIssueOfLinearParams,
|
||||
'linear.createProject': CreateProject,
|
||||
|
||||
Reference in New Issue
Block a user