feat(linear): expose issue activity history (#9667)
This commit is contained in:
parent
48578a85e2
commit
be066fe8e9
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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)`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 () => ({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
}
|
||||
Loading…
Reference in New Issue