feat(linear): expose issue activity history (#9667)

This commit is contained in:
Brennan Benson 2026-07-21 13:13:05 -07:00 committed by GitHub
parent 48578a85e2
commit be066fe8e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 640 additions and 42 deletions

View File

@ -22,18 +22,18 @@
{
"name": "linear-tickets",
"sourcePath": "skills/linear-tickets",
"releaseRevision": 4,
"packageDigest": "f198d7b22e5ee1673dac403f9cca0553b124e0a90e4fdd05d2c23b7344e32d2b",
"gitTreeSha": "de9fc106bbb4e313a90ff9a9513a720909bbd176",
"releaseRevision": 5,
"packageDigest": "126f2da6af59d622e477261b4388f9377459b04b0400e4f58ee14f133e420073",
"gitTreeSha": "f614472d0fb744b940bdfe253d7b50c907a8e8ee",
"files": [
{
"path": "SKILL.md",
"size": 10596,
"size": 10609,
"executable": false,
"classification": "text",
"exactSha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5",
"textNormalizedSha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5",
"identitySha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5"
"exactSha256": "2504de521229156e7798f3955a83e5df15e56698742c2c2e5a2a91b35d10d696",
"textNormalizedSha256": "2504de521229156e7798f3955a83e5df15e56698742c2c2e5a2a91b35d10d696",
"identitySha256": "2504de521229156e7798f3955a83e5df15e56698742c2c2e5a2a91b35d10d696"
}
]
},
@ -94,18 +94,18 @@
{
"name": "orca-linear",
"sourcePath": "skills/orca-linear",
"releaseRevision": 2,
"packageDigest": "d44d09e6ecb6a64da177083aad26a95f031cd1cf26ba059fdc888c2628aef64f",
"gitTreeSha": "c34f42030f43e5a85737996fa375bbd79cb5bea8",
"releaseRevision": 3,
"packageDigest": "9492e039d5d6bc84096b6a13454c08ef9305d6a0871bf8bfb2bea0e8a0315b73",
"gitTreeSha": "d7cf17c7f67986024b1dacfb1032112db0440958",
"files": [
{
"path": "SKILL.md",
"size": 10320,
"size": 10333,
"executable": false,
"classification": "text",
"exactSha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5",
"textNormalizedSha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5",
"identitySha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5"
"exactSha256": "6cf7adec44a919e1b6b0bd99cbf9a7c805dfdef338eb91053173ac20d33d9cec",
"textNormalizedSha256": "6cf7adec44a919e1b6b0bd99cbf9a7c805dfdef338eb91053173ac20d33d9cec",
"identitySha256": "6cf7adec44a919e1b6b0bd99cbf9a7c805dfdef338eb91053173ac20d33d9cec"
}
]
},

View File

@ -1195,6 +1195,22 @@
"identitySha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5"
}
]
},
{
"releaseRevision": 5,
"packageDigest": "126f2da6af59d622e477261b4388f9377459b04b0400e4f58ee14f133e420073",
"gitTreeSha": "f614472d0fb744b940bdfe253d7b50c907a8e8ee",
"files": [
{
"path": "SKILL.md",
"size": 10609,
"executable": false,
"classification": "text",
"exactSha256": "2504de521229156e7798f3955a83e5df15e56698742c2c2e5a2a91b35d10d696",
"textNormalizedSha256": "2504de521229156e7798f3955a83e5df15e56698742c2c2e5a2a91b35d10d696",
"identitySha256": "2504de521229156e7798f3955a83e5df15e56698742c2c2e5a2a91b35d10d696"
}
]
}
],
"orca-linear": [
@ -1229,6 +1245,22 @@
"identitySha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5"
}
]
},
{
"releaseRevision": 3,
"packageDigest": "9492e039d5d6bc84096b6a13454c08ef9305d6a0871bf8bfb2bea0e8a0315b73",
"gitTreeSha": "d7cf17c7f67986024b1dacfb1032112db0440958",
"files": [
{
"path": "SKILL.md",
"size": 10333,
"executable": false,
"classification": "text",
"exactSha256": "6cf7adec44a919e1b6b0bd99cbf9a7c805dfdef338eb91053173ac20d33d9cec",
"textNormalizedSha256": "6cf7adec44a919e1b6b0bd99cbf9a7c805dfdef338eb91053173ac20d33d9cec",
"identitySha256": "6cf7adec44a919e1b6b0bd99cbf9a7c805dfdef338eb91053173ac20d33d9cec"
}
]
}
],
"orca-emulator-android": [

View File

@ -71,7 +71,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]

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]

View File

@ -71,7 +71,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]

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

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: {

View File

@ -587,6 +587,9 @@ export function formatFlagHelp(flag: string): string {
if (flag === 'relations') {
return '--relations Include blocking, related, and duplicate links'
}
if (flag === 'activity') {
return '--activity Include issue field-change history'
}
if (flag === 'full') {
return '--full Include all supported V1 issue context within caps'
}

View File

@ -384,6 +384,7 @@ describe('orca root help', () => {
expect(issueHelp).toContain('orca linear issue [<id>]')
expect(issueHelp).toContain('--comments Include threaded Linear comments')
expect(issueHelp).toContain('--attachments Include attachment metadata and URLs')
expect(issueHelp).toContain('--activity Include issue field-change history')
expect(issueHelp).toContain('--workspace <id> Connected Linear workspace id')
expect(issueHelp).toContain('--id <id> Linear issue key, id, or URL')

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

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')

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'

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 }
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] != null) {
changes.push({ field, to: node[field] })
}
}
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 })
}

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,10 +149,17 @@ 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')
}
expect(ACTIVITY_QUERY).not.toContain('attachment { id title url source }')
})
it('does not probe grandchildren when the first child page exhausts the node cap', async () => {
@ -201,6 +240,127 @@ 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' }]
},
{
id: 'history-3',
archived: false,
trashed: false
}
],
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.objectContaining({
id: 'history-3',
actor: { kind: 'system', displayName: 'Linear' },
changes: [
{ field: 'archived', to: false },
{ field: 'trashed', to: false }
]
})
])
expect(output.meta.sections.activity).toMatchObject({
returned: 3,
cap: 250,
capReached: false
})
})
it('caps activity history at five provider pages', async () => {
for (let page = 0; page < 5; page += 1) {
plainRawRequest.mockResolvedValueOnce({
data: {
issue: {
history: {
nodes: Array.from({ length: 50 }, (_, index) => ({
id: `history-${page * 50 + index}`
})),
pageInfo: { hasNextPage: true, endCursor: `activity-cursor-${page}` }
}
}
}
})
}
const { readOptionalIncludes } = await import('./issue-context-includes')
const output = result()
await readOptionalIncludes(
resolvedIssue(),
requestWithActivity(),
output,
[],
output.meta.sections
)
expect(output.activity).toHaveLength(250)
expect(output.meta.sections.activity).toMatchObject({
returned: 250,
cap: 250,
capReached: true,
hasMore: true
})
expect(plainRawRequest).toHaveBeenCalledTimes(5)
expect(plainRawRequest.mock.calls[4]?.[1]).toEqual({
id: 'parent',
first: 50,
after: 'activity-cursor-3'
})
})
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})`

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

View File

@ -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: {

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 () => ({

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
},

View File

@ -11,6 +11,35 @@ function makeRequest(method: string, params?: unknown): RpcRequest {
}
describe('Linear agent access RPC methods', () => {
it('defaults activity off for clients using the pre-activity issue request shape', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
linearIssueContext: vi.fn().mockResolvedValue({ ok: true })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: LINEAR_AGENT_ACCESS_METHODS })
const response = await dispatcher.dispatch(
makeRequest('linear.issueContext', {
input: 'ENG-1',
include: { comments: false, children: false, attachments: false, relations: false },
depth: 0
})
)
expect(response.ok).toBe(true)
expect(runtime.linearIssueContext).toHaveBeenCalledWith({
input: 'ENG-1',
include: {
comments: false,
children: false,
attachments: false,
relations: false,
activity: false
},
depth: 0
})
})
it('routes agent write methods to the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',

View File

@ -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().default(false)
})
const LinearCurrentContext = z

View File

@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import type { LinearIssueContextResult } from '../../shared/linear-agent-access'
import { formatRemoteLinearCli } from './ssh-remote-linear-output'
describe('SSH Linear activity output', () => {
it('shows requested activity in non-json issue output', () => {
const result: LinearIssueContextResult = {
issue: {
id: 'issue-1',
identifier: 'ENG-123',
title: 'Fix thing',
url: 'https://linear.app/acme/issue/ENG-123',
labels: []
},
activity: [{ id: 'history-1', actor: { kind: 'system' }, changes: [] }],
meta: {
requested: {
current: true,
include: {
comments: false,
children: false,
attachments: false,
relations: false,
activity: true
},
depth: 2
},
resolved: {
id: 'issue-1',
identifier: 'ENG-123',
workspaceId: 'workspace-1',
workspaceName: 'Acme'
},
partial: false,
includeErrors: [],
sections: {
activity: { returned: 1, cap: 250, capReached: false, hasMore: false }
}
}
}
expect(formatRemoteLinearCli(result)?.stdout).toContain('Activity: 1')
})
})

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

View File

@ -108,7 +108,7 @@ function formatLinearIssue(result: LinearIssueContextResult): string {
if (issue.dueDate) {
lines.push(`Due: ${issue.dueDate}`)
}
for (const section of ['comments', 'children', 'attachments', 'relations'] as const) {
for (const section of ['comments', 'children', 'attachments', 'relations', 'activity'] as const) {
const meta = result.meta.sections[section]
if (meta) {
lines.push(`${section[0].toUpperCase()}${section.slice(1)}: ${meta.returned}`)

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')

View File

@ -9,6 +9,7 @@ export const LINEAR_ISSUE_FLAGS = new Set([
'depth',
'attachments',
'relations',
'activity',
'full',
'workspace',
'id'

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

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: {
@ -478,4 +484,24 @@ describe('runRemoteOrcaCli', () => {
expect(result.stderr).toContain('Unsupported SSH Orca CLI command: worktree list')
expect(result.stderr).toContain('full Orca CLI bridge unavailable')
})
it('does not parse Android --activity values as Linear boolean flags', async () => {
const { runtime } = createRuntime()
const result = await runRemoteOrcaCli(
runtime,
{
argv: ['emulator', 'launch', 'com.acme.app', '--activity', '.MainActivity'],
cwd: '/home/alice',
env: {}
},
LEGACY_FALLBACK_OPTIONS
)
expect(result.exitCode).toBe(1)
expect(result.stderr).toContain(
'Unsupported SSH Orca CLI command: emulator launch com.acme.app'
)
expect(result.stderr).not.toContain('com.acme.app .MainActivity')
})
})

View File

@ -257,7 +257,7 @@ function parseRemoteCliArgs(argv: string[]): ParsedRemoteCli {
const flag = assignment
const next = argv[i + 1]
if (!REMOTE_BOOLEAN_FLAGS.has(flag) && next && !next.startsWith('--')) {
if (!isRemoteBooleanFlag(flag, commandPath) && next && !next.startsWith('--')) {
setRemoteFlag(flags, flag, next)
i += 1
} else {
@ -267,6 +267,14 @@ function parseRemoteCliArgs(argv: string[]): ParsedRemoteCli {
return { commandPath, flags }
}
function isRemoteBooleanFlag(flag: string, commandPath: string[]): boolean {
// Why: Android launch already uses --activity <name>; only Linear issue reads use it as a boolean.
return (
REMOTE_BOOLEAN_FLAGS.has(flag) ||
(flag === 'activity' && commandPath[0] === 'linear' && commandPath[1] === 'issue')
)
}
function setRemoteFlag(
flags: Map<string, string | boolean>,
name: string,

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 = {

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: {

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[]
}