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:
Brennan Benson
2026-10-17 13:14:48 -07:00
committed by GitHub
parent 216e3e42a6
commit 21e3d76576
25 changed files with 1023 additions and 70 deletions
+14
View File
@@ -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.
+14
View File
@@ -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
+2 -33
View File
@@ -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'],
+63
View File
@@ -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)
}
}
+31
View File
@@ -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(
[
+2 -16
View File
@@ -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),
+3
View File
@@ -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'
}
+10
View File
@@ -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}.`
+37
View File
@@ -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
}
+29
View File
@@ -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',
+5 -2
View File
@@ -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.')
}
+54
View File
@@ -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 },
+17 -10
View File
@@ -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 {
+27
View File
@@ -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']),
+4
View File
@@ -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,