feat(linear): expose issue activity history

This commit is contained in:
Brennan Benson
2026-07-21 13:02:36 -07:00
parent 1bebb24015
commit e62a37ceee
23 changed files with 430 additions and 23 deletions
+1 -1
View File
@@ -68,7 +68,7 @@ Do not use `orca linear attach` to read screenshots. That command creates link a
## Common Commands
```bash
orca linear issue [<id>] [--current] [--comments] [--children] [--depth <n>] [--attachments] [--relations] [--full] [--workspace <id>] [--json]
orca linear issue [<id>] [--current] [--comments] [--children] [--depth <n>] [--attachments] [--relations] [--activity] [--full] [--workspace <id>] [--json]
orca linear search <query> [--limit <n>] [--workspace <id>|all] [--json]
orca linear team list [--workspace <id>|all] [--json]
orca linear team members --team <key|id> [--workspace <id>] [--json]
+1 -1
View File
@@ -68,7 +68,7 @@ Do not use `orca linear attach` to read screenshots. That command creates link a
## Common Commands
```bash
orca linear issue [<id>] [--current] [--comments] [--children] [--depth <n>] [--attachments] [--relations] [--full] [--workspace <id>] [--json]
orca linear issue [<id>] [--current] [--comments] [--children] [--depth <n>] [--attachments] [--relations] [--activity] [--full] [--workspace <id>] [--json]
orca linear search <query> [--limit <n>] [--workspace <id>|all] [--json]
orca linear team list [--workspace <id>|all] [--json]
orca linear team members --team <key|id> [--workspace <id>] [--json]
File diff suppressed because one or more lines are too long
+11 -3
View File
@@ -81,7 +81,8 @@ describe('orca linear CLI handlers', () => {
comments: true,
children: true,
attachments: true,
relations: true
relations: true,
activity: true
},
depth: 2,
context: {
@@ -106,7 +107,8 @@ describe('orca linear CLI handlers', () => {
comments: true,
children: true,
attachments: true,
relations: true
relations: true,
activity: true
})
}),
{ timeoutMs: 120_000 }
@@ -488,7 +490,13 @@ function issueResult(): unknown {
meta: {
requested: {
current: false,
include: { comments: false, children: false, attachments: false, relations: false },
include: {
comments: false,
children: false,
attachments: false,
relations: false,
activity: false
},
depth: 2
},
resolved: {
+3
View File
@@ -54,6 +54,9 @@ export function formatLinearIssue(result: LinearIssueContextResult): string {
if (sections.relations) {
lines.push(`Relations: ${sections.relations.returned}`)
}
if (sections.activity) {
lines.push(`Activity: ${sections.activity.returned}`)
}
if (result.inlineMedia?.length) {
lines.push(`Inline media: ${result.inlineMedia.length} (use --json for URLs)`)
}
+2 -1
View File
@@ -122,7 +122,8 @@ export function buildIssueRequest(
comments: full || flags.get('comments') === true,
children: full || flags.get('children') === true,
attachments: full || flags.get('attachments') === true,
relations: full || flags.get('relations') === true
relations: full || flags.get('relations') === true,
activity: full || flags.get('activity') === true
}
if (flags.has('depth') && !includes.children) {
throw new RuntimeClientError('invalid_argument', '--depth requires --children or --full')
+2 -1
View File
@@ -6,7 +6,7 @@ export const LINEAR_COMMAND_SPECS: CommandSpec[] = [
path: ['linear', 'issue'],
summary: 'Read Linear issue context for agents',
usage:
'orca linear issue [<id>] [--current] [--comments] [--children] [--depth <n>] [--attachments] [--relations] [--full] [--workspace <id>] [--json]',
'orca linear issue [<id>] [--current] [--comments] [--children] [--depth <n>] [--attachments] [--relations] [--activity] [--full] [--workspace <id>] [--json]',
allowedFlags: [
...GLOBAL_FLAGS,
'current',
@@ -15,6 +15,7 @@ export const LINEAR_COMMAND_SPECS: CommandSpec[] = [
'depth',
'attachments',
'relations',
'activity',
'full',
'workspace',
'id'
+185
View File
@@ -0,0 +1,185 @@
import type {
LinearIssueActivityEntry,
LinearIssueActivityValue
} from '../../shared/linear-issue-activity'
import type { RawNamedEntity, RawPageInfo, RawUser } from './issue-context-raw'
type RawActivityEntity = RawNamedEntity & {
identifier?: string | null
title?: string | null
url?: string | null
}
type RawActivityActorBot = {
id?: string | null
name?: string | null
avatarUrl?: string | null
type?: string | null
subType?: string | null
userDisplayName?: string | null
}
export type RawActivityNode = {
id: string
createdAt?: string | null
updatedAt?: string | null
actor?: RawUser | null
botActor?: RawActivityActorBot | null
fromTitle?: string | null
toTitle?: string | null
updatedDescription?: boolean | null
fromPriority?: number | null
toPriority?: number | null
fromEstimate?: number | null
toEstimate?: number | null
fromDueDate?: string | null
toDueDate?: string | null
fromAssignee?: RawUser | null
toAssignee?: RawUser | null
fromDelegate?: RawUser | null
toDelegate?: RawUser | null
fromState?: RawActivityEntity | null
toState?: RawActivityEntity | null
fromProject?: RawActivityEntity | null
toProject?: RawActivityEntity | null
fromCycle?: RawActivityEntity | null
toCycle?: RawActivityEntity | null
fromParent?: RawActivityEntity | null
toParent?: RawActivityEntity | null
fromTeam?: RawActivityEntity | null
toTeam?: RawActivityEntity | null
fromProjectMilestone?: RawActivityEntity | null
toProjectMilestone?: RawActivityEntity | null
addedLabels?: RawActivityEntity[] | null
removedLabels?: RawActivityEntity[] | null
relationChanges?: { identifier?: string | null; type?: string | null }[] | null
attachment?: RawActivityEntity | null
archived?: boolean | null
autoArchived?: boolean | null
autoClosed?: boolean | null
trashed?: boolean | null
}
export type RawActivityResponse = {
issue?: {
history?: { nodes?: RawActivityNode[]; pageInfo?: RawPageInfo } | null
} | null
}
export const ACTIVITY_QUERY = `
query OrcaAgentLinearIssueActivity($id: String!, $first: Int, $after: String) {
issue(id: $id) {
history(first: $first, after: $after) {
nodes {
id
createdAt
updatedAt
actor { id displayName avatarUrl }
botActor { id name avatarUrl type subType userDisplayName }
fromTitle
toTitle
updatedDescription
fromPriority
toPriority
fromEstimate
toEstimate
fromDueDate
toDueDate
fromAssignee { id displayName avatarUrl }
toAssignee { id displayName avatarUrl }
fromDelegate { id displayName avatarUrl }
toDelegate { id displayName avatarUrl }
fromState { id name type color }
toState { id name type color }
fromProject { id name color }
toProject { id name color }
fromCycle { id name }
toCycle { id name }
fromParent { id identifier title url }
toParent { id identifier title url }
fromTeam { id name key color }
toTeam { id name key color }
fromProjectMilestone { id name }
toProjectMilestone { id name }
addedLabels { id name color }
removedLabels { id name color }
relationChanges { identifier type }
attachment { id title url source }
archived
autoArchived
autoClosed
trashed
}
pageInfo { hasNextPage endCursor }
}
}
}
`
export function mapActivity(node: RawActivityNode): LinearIssueActivityEntry {
const changes: LinearIssueActivityEntry['changes'] = []
pushActivityChange(changes, 'title', node.fromTitle, node.toTitle)
if (node.updatedDescription) {
changes.push({ field: 'description' })
}
pushActivityChange(changes, 'priority', node.fromPriority, node.toPriority)
pushActivityChange(changes, 'estimate', node.fromEstimate, node.toEstimate)
pushActivityChange(changes, 'dueDate', node.fromDueDate, node.toDueDate)
pushActivityChange(changes, 'assignee', node.fromAssignee, node.toAssignee)
pushActivityChange(changes, 'delegate', node.fromDelegate, node.toDelegate)
pushActivityChange(changes, 'state', node.fromState, node.toState)
pushActivityChange(changes, 'project', node.fromProject, node.toProject)
pushActivityChange(changes, 'cycle', node.fromCycle, node.toCycle)
pushActivityChange(changes, 'parent', node.fromParent, node.toParent)
pushActivityChange(changes, 'team', node.fromTeam, node.toTeam)
pushActivityChange(changes, 'milestone', node.fromProjectMilestone, node.toProjectMilestone)
if (node.addedLabels?.length) {
changes.push({ field: 'labelsAdded', to: node.addedLabels })
}
if (node.removedLabels?.length) {
changes.push({ field: 'labelsRemoved', from: node.removedLabels })
}
if (node.relationChanges?.length) {
changes.push({ field: 'relations', to: node.relationChanges })
}
if (node.attachment) {
changes.push({ field: 'attachment', to: node.attachment })
}
for (const field of ['archived', 'autoArchived', 'autoClosed', 'trashed'] as const) {
if (node[field]) {
changes.push({ field, to: true })
}
}
const bot = node.botActor
return {
id: node.id,
createdAt: node.createdAt,
updatedAt: node.updatedAt,
actor: node.actor
? { ...node.actor, kind: 'user' }
: bot
? {
id: bot.id,
displayName: bot.userDisplayName ?? bot.name,
avatarUrl: bot.avatarUrl,
name: bot.name,
type: bot.type,
subType: bot.subType,
kind: 'bot'
}
: { kind: 'system', displayName: 'Linear' },
changes
}
}
function pushActivityChange(
changes: LinearIssueActivityEntry['changes'],
field: string,
from: LinearIssueActivityValue | undefined,
to: LinearIssueActivityValue | undefined
): void {
if (from == null && to == null) {
return
}
changes.push({ field, from: from ?? null, to: to ?? null })
}
+109 -4
View File
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LinearIssueContextResult, LinearIssueRequest } from '../../shared/linear-agent-access'
import type { ResolvedIssue } from './issue-context-client'
import { ACTIVITY_QUERY } from './issue-activity-raw'
import {
ATTACHMENTS_QUERY,
CHILDREN_QUERY,
@@ -70,7 +71,13 @@ function resolvedIssue(): ResolvedIssue {
function request(): LinearIssueRequest {
return {
include: { comments: false, children: true, attachments: false, relations: false },
include: {
comments: false,
children: true,
attachments: false,
relations: false,
activity: false
},
depth: 2
}
}
@@ -84,7 +91,26 @@ function requestWithDepth(depth: number): LinearIssueRequest {
function requestWithComments(): LinearIssueRequest {
return {
include: { comments: true, children: false, attachments: false, relations: false },
include: {
comments: true,
children: false,
attachments: false,
relations: false,
activity: false
},
depth: 2
}
}
function requestWithActivity(): LinearIssueRequest {
return {
include: {
comments: false,
children: false,
attachments: false,
relations: false,
activity: true
},
depth: 2
}
}
@@ -95,7 +121,13 @@ function result(): LinearIssueContextResult {
meta: {
requested: {
current: false,
include: { comments: false, children: true, attachments: false, relations: false },
include: {
comments: false,
children: true,
attachments: false,
relations: false,
activity: false
},
depth: 2
},
resolved: {
@@ -117,7 +149,13 @@ describe('Linear issue context includes', () => {
})
it('declares cursor variables on every paged include query', () => {
for (const query of [COMMENTS_QUERY, CHILDREN_QUERY, ATTACHMENTS_QUERY, RELATIONS_QUERY]) {
for (const query of [
COMMENTS_QUERY,
CHILDREN_QUERY,
ATTACHMENTS_QUERY,
RELATIONS_QUERY,
ACTIVITY_QUERY
]) {
expect(query).toContain('$after: String')
expect(query).toContain('after: $after')
}
@@ -201,6 +239,73 @@ describe('Linear issue context includes', () => {
})
})
it('returns normalized issue activity with user, bot, and field changes', async () => {
plainRawRequest.mockResolvedValueOnce({
data: {
issue: {
history: {
nodes: [
{
id: 'history-1',
createdAt: '2026-07-20T10:00:00.000Z',
actor: { id: 'user-1', displayName: 'Ada' },
fromState: { id: 'todo', name: 'Todo' },
toState: { id: 'started', name: 'In Progress' },
addedLabels: [{ id: 'bug', name: 'Bug' }]
},
{
id: 'history-2',
botActor: { id: 'bot-1', name: 'Workflow', type: 'app' },
fromPriority: 3,
toPriority: 2,
relationChanges: [{ identifier: 'ENG-9', type: 'blocks' }]
}
],
pageInfo: { hasNextPage: false }
}
}
}
})
const { readOptionalIncludes } = await import('./issue-context-includes')
const output = result()
await readOptionalIncludes(
resolvedIssue(),
requestWithActivity(),
output,
[],
output.meta.sections
)
expect(output.activity).toEqual([
expect.objectContaining({
id: 'history-1',
actor: expect.objectContaining({ kind: 'user', displayName: 'Ada' }),
changes: [
{
field: 'state',
from: { id: 'todo', name: 'Todo' },
to: { id: 'started', name: 'In Progress' }
},
{ field: 'labelsAdded', to: [{ id: 'bug', name: 'Bug' }] }
]
}),
expect.objectContaining({
id: 'history-2',
actor: expect.objectContaining({ kind: 'bot', name: 'Workflow' }),
changes: [
{ field: 'priority', from: 3, to: 2 },
{ field: 'relations', to: [{ identifier: 'ENG-9', type: 'blocks' }] }
]
})
])
expect(output.meta.sections.activity).toMatchObject({
returned: 2,
cap: 250,
capReached: false
})
})
it('extracts comment media past the body truncation cap', async () => {
const url = 'https://uploads.linear.app/w/file/late?sig=1'
const body = `${'x'.repeat(20_001)}\n![late](${url})`
+36
View File
@@ -1,6 +1,7 @@
import type {
LinearCollectionMeta,
LinearIssueAttachment,
LinearIssueActivityEntry,
LinearIssueChildNode,
LinearIssueCommentNode,
LinearIssueContextResult,
@@ -10,6 +11,7 @@ import type {
} from '../../shared/linear-agent-access'
import {
LINEAR_ATTACHMENTS_CAP,
LINEAR_ACTIVITY_CAP,
LINEAR_CHILDREN_NODE_CAP,
LINEAR_COMMENTS_CAP,
LINEAR_COMMENT_BODY_CAP,
@@ -22,6 +24,7 @@ import { getRequiredEntry, withLinearRead } from './issue-context-client'
import { getPublicFileUrlClient } from './client'
import { includeErrorCode } from './issue-context-errors'
import { readConnectionPages } from './issue-context-pagination'
import { ACTIVITY_QUERY, mapActivity, type RawActivityResponse } from './issue-activity-raw'
import {
ATTACHMENTS_QUERY,
CHILDREN_QUERY,
@@ -58,6 +61,9 @@ export async function readOptionalIncludes(
if (request.include.relations) {
includeTasks.push(['relations', async () => assignRelations(resolved, result, sections)])
}
if (request.include.activity) {
includeTasks.push(['activity', async () => assignActivity(resolved, result, sections)])
}
for (const [include, task] of includeTasks) {
try {
@@ -113,6 +119,16 @@ async function assignRelations(
sections.relations = read.meta
}
async function assignActivity(
resolved: ResolvedIssue,
result: LinearIssueContextResult,
sections: LinearIssueContextResult['meta']['sections']
): Promise<void> {
const read = await readActivity(resolved)
result.activity = read.items
sections.activity = read.meta
}
async function readComments(resolved: ResolvedIssue): Promise<{
items: LinearIssueCommentNode[]
meta: LinearCollectionMeta
@@ -279,3 +295,23 @@ async function readRelations(
meta: collectionMeta(items.length, LINEAR_RELATIONS_CAP, response.hasMore)
}
}
async function readActivity(
resolved: ResolvedIssue
): Promise<{ items: LinearIssueActivityEntry[]; meta: LinearCollectionMeta }> {
const entry = getRequiredEntry(resolved.workspace.id)
const response = await readConnectionPages(LINEAR_ACTIVITY_CAP, async (page) => {
return await withLinearRead(entry, async () => {
const raw = await entry.client.client.rawRequest<
RawActivityResponse,
Record<string, unknown>
>(ACTIVITY_QUERY, { id: resolved.issue.id, ...page })
return raw.data?.issue?.history ?? null
})
})
const items = response.nodes.slice(0, LINEAR_ACTIVITY_CAP).map(mapActivity)
return {
items,
meta: collectionMeta(items.length, LINEAR_ACTIVITY_CAP, response.hasMore)
}
}
@@ -28,7 +28,13 @@ function resultWith(overrides: Partial<LinearIssueContextResult>): LinearIssueCo
meta: {
requested: {
current: false,
include: { comments: true, children: true, attachments: false, relations: false },
include: {
comments: true,
children: true,
attachments: false,
relations: false,
activity: false
},
depth: 3
},
resolved: {
+7 -1
View File
@@ -70,7 +70,13 @@ describe('Linear issue context', () => {
readLinearIssueContext(
{
current: true,
include: { attachments: false, children: false, comments: false, relations: false },
include: {
attachments: false,
children: false,
comments: false,
relations: false,
activity: false
},
depth: 0
},
async () => ({
+7 -1
View File
@@ -25374,7 +25374,13 @@ export class OrcaRuntimeService {
input: params.input,
current: params.current,
workspaceId: params.workspaceId,
include: { comments: false, children: false, attachments: false, relations: false },
include: {
comments: false,
children: false,
attachments: false,
relations: false,
activity: false
},
depth: 0,
context: params.context
},
@@ -45,7 +45,8 @@ const LinearIncludeFlags = z.object({
comments: z.boolean(),
children: z.boolean(),
attachments: z.boolean(),
relations: z.boolean()
relations: z.boolean(),
activity: z.boolean()
})
const LinearCurrentContext = z
+2 -3
View File
@@ -29,7 +29,6 @@ function createRuntime() {
meta: {
requested: {
current: true,
include: { comments: true, children: true, attachments: true, relations: true },
depth: 2
},
resolved: {
@@ -214,7 +213,7 @@ describe('runRemoteOrcaCli Linear commands', () => {
expect(payload.ok).toBe(true)
expect(payload.result.request).toMatchObject({
current: true,
include: { comments: true, children: true, attachments: true, relations: true },
include: expect.objectContaining({ activity: true }),
context: {
remote: true,
terminalHandle: 'term_ssh',
@@ -241,7 +240,7 @@ describe('runRemoteOrcaCli Linear commands', () => {
expect(payload.ok).toBe(true)
expect(payload.result.request).toMatchObject({
input: 'ENG-123',
include: { comments: true, children: true, attachments: true, relations: true }
include: expect.objectContaining({ activity: true })
})
})
+2 -1
View File
@@ -178,7 +178,8 @@ function buildRemoteLinearIssueRequest(
comments: full || parsed.flags.get('comments') === true,
children: full || parsed.flags.get('children') === true,
attachments: full || parsed.flags.get('attachments') === true,
relations: full || parsed.flags.get('relations') === true
relations: full || parsed.flags.get('relations') === true,
activity: full || parsed.flags.get('activity') === true
}
if (parsed.flags.has('depth') && !include.children) {
throw new RemoteCliArgumentError('invalid_argument', '--depth requires --children or --full')
@@ -9,6 +9,7 @@ export const LINEAR_ISSUE_FLAGS = new Set([
'depth',
'attachments',
'relations',
'activity',
'full',
'workspace',
'id'
+2 -1
View File
@@ -69,7 +69,7 @@ Run \`orca linear <command> --help\` for command-specific usage.`
const LINEAR_ISSUE_HELP = `orca linear issue
Usage: orca linear issue [<id>] [--current] [--comments] [--children] [--depth <n>] [--attachments] [--relations] [--full] [--workspace <id>] [--json]
Usage: orca linear issue [<id>] [--current] [--comments] [--children] [--depth <n>] [--attachments] [--relations] [--activity] [--full] [--workspace <id>] [--json]
Read Linear issue context for agents
@@ -84,6 +84,7 @@ Options:
--depth <n> Child issue depth for --children/--full
--attachments Include attachment metadata and URLs
--relations Include blocking, related, and duplicate links
--activity Include issue field-change history
--full Include all supported V1 issue context within caps
--workspace <id> Connected Linear workspace id
--id <id> Linear issue key, id, or URL
+7 -1
View File
@@ -107,7 +107,13 @@ describe('runRemoteOrcaCli', () => {
meta: {
requested: {
current: true,
include: { comments: true, children: true, attachments: true, relations: true },
include: {
comments: true,
children: true,
attachments: true,
relations: true,
activity: true
},
depth: 2
},
resolved: {
+1
View File
@@ -34,6 +34,7 @@ const HOST_INTERACTIVE_COMMANDS: Record<string, string> = {
const REMOTE_BOOLEAN_FLAGS = new Set([
'all',
'activity',
'attachments',
'children',
'comments',
+3 -1
View File
@@ -7,6 +7,7 @@ export const LINEAR_CHILDREN_MAX_DEPTH = 5
export const LINEAR_CHILDREN_NODE_CAP = 200
export const LINEAR_ATTACHMENTS_CAP = 100
export const LINEAR_RELATIONS_CAP = 100
export const LINEAR_ACTIVITY_CAP = 250
export const LINEAR_WRITE_BODY_CAP = 65_000
export const LINEAR_ERROR_CODES = [
@@ -38,7 +39,7 @@ export const LINEAR_ERROR_CODES = [
export type LinearErrorCode = (typeof LINEAR_ERROR_CODES)[number]
export type LinearIssueInclude = 'comments' | 'children' | 'attachments' | 'relations'
export type LinearIssueInclude = 'comments' | 'children' | 'attachments' | 'relations' | 'activity'
export type LinearIncludeErrorCode =
| 'linear_timeout'
@@ -91,6 +92,7 @@ export type {
LinearWriteIssueRef,
LinearIssueTaskUpdateResult
} from './linear-agent-result-types'
export type { LinearIssueActivityEntry, LinearIssueActivityValue } from './linear-issue-activity'
export type { LinearInlineMedia } from './linear-inline-media'
export type LinearWriteTargetRequest = {
+2
View File
@@ -6,6 +6,7 @@ import type {
LinearIssueTaskUpdateRequest
} from './linear-agent-access'
import type { LinearInlineMedia } from './linear-inline-media'
import type { LinearIssueActivityEntry } from './linear-issue-activity'
export type LinearIssueSummary = {
id: string
@@ -88,6 +89,7 @@ export type LinearIssueContextResult = {
children?: LinearIssueChildNode[]
attachments?: LinearIssueAttachment[]
relations?: LinearIssueRelation[]
activity?: LinearIssueActivityEntry[]
inlineMedia?: LinearInlineMedia[]
meta: {
requested: {
+36
View File
@@ -0,0 +1,36 @@
import type { LinearNamedEntity, LinearUserSummary } from './linear-agent-result-types'
export type LinearIssueActivityActor = LinearUserSummary & {
kind: 'user' | 'bot' | 'system'
name?: string | null
type?: string | null
subType?: string | null
}
export type LinearIssueActivityEntity = LinearNamedEntity & {
identifier?: string | null
title?: string | null
url?: string | null
}
export type LinearIssueActivityValue =
| string
| number
| boolean
| LinearIssueActivityEntity
| LinearIssueActivityEntity[]
| null
export type LinearIssueActivityChange = {
field: string
from?: LinearIssueActivityValue
to?: LinearIssueActivityValue
}
export type LinearIssueActivityEntry = {
id: string
createdAt?: string | null
updatedAt?: string | null
actor: LinearIssueActivityActor
changes: LinearIssueActivityChange[]
}