diff --git a/apps/docs/integrations/linear.mdx b/apps/docs/integrations/linear.mdx index 889bc4ef4..878dfbcac 100644 --- a/apps/docs/integrations/linear.mdx +++ b/apps/docs/integrations/linear.mdx @@ -73,8 +73,9 @@ previews. The Brain keeps one durable page per visible Linear issue, including its current workflow state, team, project, priority, labels, assignee, description, and a -bounded set of recent comments. Collection uses the same workspace connection -configured above; it does not require another Linear token. +bounded set of recent comments. When available, issue pages also include cycle, +estimate, start date, parent, and related-issue context. Collection uses the same +workspace connection configured above; it does not require another Linear token. Roomote refreshes changed issues incrementally and periodically checks the full visible issue set. Archived issues remain available as historical context. If an diff --git a/packages/linear/src/__tests__/linear-client-brain.test.ts b/packages/linear/src/__tests__/linear-client-brain.test.ts index c75d1a669..10d737dd6 100644 --- a/packages/linear/src/__tests__/linear-client-brain.test.ts +++ b/packages/linear/src/__tests__/linear-client-brain.test.ts @@ -21,9 +21,43 @@ describe('LinearClient.listIssuesForBrain', () => { title: 'Collect Linear issues', description: null, url: 'https://linear.app/acme/issue/ENG-1', + estimate: 3, createdAt: '2026-08-01T00:00:00.000Z', updatedAt: '2026-08-02T00:00:00.000Z', - labels: { nodes: [{ name: 'brain' }] }, + startedAt: '2026-08-01T12:00:00.000Z', + cycle: { name: 'August', number: 12 }, + parent: { + id: 'parent-1', + identifier: 'ENG-0', + title: 'Brain ingestion', + }, + labels: { nodes: [{ name: 'memory' }, { name: 'brain' }] }, + relations: { + nodes: [ + { + type: 'blocks', + relatedIssue: { + id: 'issue-2', + identifier: 'ENG-2', + title: 'Search Linear issues', + }, + }, + ], + pageInfo: { hasNextPage: true }, + }, + inverseRelations: { + nodes: [ + { + type: 'duplicate', + issue: { + id: 'issue-3', + identifier: 'ENG-3', + title: 'Index Linear issues', + }, + }, + ], + pageInfo: { hasNextPage: false }, + }, comments: { nodes: [ { @@ -66,7 +100,22 @@ describe('LinearClient.listIssuesForBrain', () => { issues: [ expect.objectContaining({ id: 'issue-1', - labels: ['brain'], + estimate: 3, + startedAt: '2026-08-01T12:00:00.000Z', + cycle: { name: 'August', number: 12 }, + parent: expect.objectContaining({ identifier: 'ENG-0' }), + labels: ['brain', 'memory'], + relationships: [ + expect.objectContaining({ + type: 'blocks', + direction: 'outbound', + }), + expect.objectContaining({ + type: 'duplicate', + direction: 'inbound', + }), + ], + relationshipsTruncated: true, comments: [expect.objectContaining({ author: 'External author' })], }), ], diff --git a/packages/linear/src/linear-client.ts b/packages/linear/src/linear-client.ts index 271a60817..f618f74dd 100644 --- a/packages/linear/src/linear-client.ts +++ b/packages/linear/src/linear-client.ts @@ -81,8 +81,10 @@ export class LinearClient { url priority priorityLabel + estimate createdAt updatedAt + startedAt completedAt canceledAt archivedAt @@ -90,9 +92,19 @@ export class LinearClient { state { name type } team { key name } project { name } + cycle { name number } + parent { id identifier title } creator { name } assignee { name } labels { nodes { name } } + relations(first: 20) { + nodes { type relatedIssue { id identifier title } } + pageInfo { hasNextPage } + } + inverseRelations(first: 20) { + nodes { type issue { id identifier title } } + pageInfo { hasNextPage } + } comments(last: 20, orderBy: createdAt) { nodes { id @@ -143,8 +155,10 @@ export class LinearClient { url: string; priority?: number | null; priorityLabel?: string | null; + estimate?: number | null; createdAt: string; updatedAt: string; + startedAt?: string | null; completedAt?: string | null; canceledAt?: string | null; archivedAt?: string | null; @@ -152,9 +166,29 @@ export class LinearClient { state?: { name: string; type: string } | null; team?: { key: string; name: string } | null; project?: { name: string } | null; + cycle?: { name?: string | null; number: number } | null; + parent?: { + id: string; + identifier: string; + title: string; + } | null; creator?: { name: string } | null; assignee?: { name: string } | null; labels?: { nodes?: Array<{ name: string }> }; + relations?: { + nodes?: Array<{ + type: string; + relatedIssue: { id: string; identifier: string; title: string }; + }>; + pageInfo?: { hasNextPage?: boolean }; + }; + inverseRelations?: { + nodes?: Array<{ + type: string; + issue: { id: string; identifier: string; title: string }; + }>; + pageInfo?: { hasNextPage?: boolean }; + }; comments?: { nodes?: Array<{ id: string; @@ -181,8 +215,10 @@ export class LinearClient { url: issue.url, priority: issue.priority ?? null, priorityLabel: issue.priorityLabel ?? null, + estimate: issue.estimate ?? null, createdAt: issue.createdAt, updatedAt: issue.updatedAt, + startedAt: issue.startedAt ?? null, completedAt: issue.completedAt ?? null, canceledAt: issue.canceledAt ?? null, archivedAt: issue.archivedAt ?? null, @@ -190,9 +226,30 @@ export class LinearClient { state: issue.state ?? null, team: issue.team ?? null, project: issue.project ?? null, + cycle: issue.cycle + ? { name: issue.cycle.name ?? null, number: issue.cycle.number } + : null, + parent: issue.parent ?? null, creator: issue.creator ?? null, assignee: issue.assignee ?? null, - labels: (issue.labels?.nodes ?? []).map((label) => label.name), + labels: (issue.labels?.nodes ?? []) + .map((label) => label.name) + .sort((a, b) => a.localeCompare(b)), + relationships: [ + ...(issue.relations?.nodes ?? []).map((relation) => ({ + type: relation.type, + direction: 'outbound' as const, + issue: relation.relatedIssue, + })), + ...(issue.inverseRelations?.nodes ?? []).map((relation) => ({ + type: relation.type, + direction: 'inbound' as const, + issue: relation.issue, + })), + ], + relationshipsTruncated: + issue.relations?.pageInfo?.hasNextPage === true || + issue.inverseRelations?.pageInfo?.hasNextPage === true, comments: (issue.comments?.nodes ?? []).map((comment) => ({ id: comment.id, body: comment.body, diff --git a/packages/linear/src/types.ts b/packages/linear/src/types.ts index ff564e428..e241d469f 100644 --- a/packages/linear/src/types.ts +++ b/packages/linear/src/types.ts @@ -271,8 +271,10 @@ export interface LinearBrainIssue { url: string; priority: number | null; priorityLabel: string | null; + estimate: number | null; createdAt: string; updatedAt: string; + startedAt: string | null; completedAt: string | null; canceledAt: string | null; archivedAt: string | null; @@ -280,9 +282,17 @@ export interface LinearBrainIssue { state: { name: string; type: string } | null; team: { key: string; name: string } | null; project: { name: string } | null; + cycle: { name: string | null; number: number } | null; + parent: { id: string; identifier: string; title: string } | null; creator: { name: string } | null; assignee: { name: string } | null; labels: string[]; + relationships: Array<{ + type: string; + direction: 'outbound' | 'inbound'; + issue: { id: string; identifier: string; title: string }; + }>; + relationshipsTruncated: boolean; comments: Array<{ id: string; body: string; diff --git a/packages/sdk/src/server/lib/__tests__/brain-linear.test.ts b/packages/sdk/src/server/lib/__tests__/brain-linear.test.ts index 8523d4c10..b775c6ee1 100644 --- a/packages/sdk/src/server/lib/__tests__/brain-linear.test.ts +++ b/packages/sdk/src/server/lib/__tests__/brain-linear.test.ts @@ -58,8 +58,10 @@ const issue = { url: 'https://linear.app/acme/issue/ENG-42', priority: 2, priorityLabel: 'High', + estimate: 3, createdAt: '2026-08-01T10:00:00.000Z', updatedAt: '2026-08-03T12:00:00.000Z', + startedAt: '2026-08-02T08:00:00.000Z', completedAt: '2026-08-03T12:00:00.000Z', canceledAt: null, archivedAt: null, @@ -67,9 +69,45 @@ const issue = { state: { name: 'Done', type: 'completed' }, team: { key: 'ENG', name: 'Engineering' }, project: { name: 'Previews' }, + cycle: { name: 'August', number: 12 }, + parent: { + id: 'parent-uuid', + identifier: 'ENG-40', + title: 'Preview reliability', + }, creator: { name: 'Ada' }, assignee: { name: 'Grace' }, labels: ['bug', 'customer'], + relationships: [ + { + type: 'related', + direction: 'outbound' as const, + issue: { + id: 'related-z', + identifier: 'ENG-44', + title: 'Track preview health', + }, + }, + { + type: 'blocks', + direction: 'inbound' as const, + issue: { + id: 'related-a', + identifier: 'ENG-41', + title: 'Renew preview leases', + }, + }, + { + type: 'related', + direction: 'inbound' as const, + issue: { + id: 'related-b', + identifier: 'ENG-43', + title: 'Report preview status', + }, + }, + ], + relationshipsTruncated: true, comments: [ { id: 'comment-1', @@ -110,12 +148,36 @@ describe('buildLinearIssuePage', () => { expect(page?.content).toContain('team: "Engineering"'); expect(page?.content).toContain('project: "Previews"'); expect(page?.content).toContain('state: "Done"'); + expect(page?.content).not.toContain('**Status**'); + expect(page?.content).not.toContain('**Priority**'); + expect(page?.content).not.toContain('**Assignee**'); + expect(page?.content).toContain( + '## Metadata\n\n- **Started**: 2026-08-02T08:00:00.000Z\n- **Estimate**: 3\n- **Cycle**: August (#12)\n- **Parent**: [ENG-40: Preview reliability](linear/org-uuid/issues/parent-uuid)\n- **Blocked by**: [ENG-41: Renew preview leases](linear/org-uuid/issues/related-a)\n- **Related issues**: [ENG-43: Report preview status](linear/org-uuid/issues/related-b), [ENG-44: Track preview health](linear/org-uuid/issues/related-z)\n_Linear truncated the relationship list; open the source issue for the rest._', + ); expect(page?.content).toContain('## Discussion'); expect(page?.content).toContain('The controller must renew the lease.'); expect(page?.content).toContain('provenance: roomote-linear-issues'); expect(page?.content).not.toContain('@'); }); + it('omits metadata when the issue has no additional values', () => { + const page = buildLinearIssuePage({ + organizationId: 'org', + organizationName: null, + issue: { + ...issue, + estimate: null, + startedAt: null, + cycle: null, + parent: null, + relationships: [], + relationshipsTruncated: false, + }, + }); + + expect(page?.content).not.toContain('## Metadata'); + }); + it('bounds issue and comment text', () => { const page = buildLinearIssuePage({ organizationId: 'org', @@ -147,7 +209,7 @@ describe('Linear issue collection', () => { expect(result.pages).toHaveLength(1); expect(result.itemUpdates).toEqual([ expect.objectContaining({ - collectorId: 'linear-issues:entity-census-v1', + collectorId: 'linear-issues:entity-census-v2', itemId: 'Issue-UUID', }), ]); @@ -175,14 +237,14 @@ describe('Linear issue collection', () => { const result = await collectBrainLinearIssues({ now, limit: 100 }); expect(result.stateUpdates[0]).toEqual({ - collectorId: 'linear-issues:entity-census-v1:incremental', + collectorId: 'linear-issues:entity-census-v2:incremental', watermark: new Date('2026-08-20T11:59:59.000Z'), cursor: null, }); }); it('re-arms a completed census after one day', async () => { - mocks.syncState.set('linear-issues:entity-census-v1', { + mocks.syncState.set('linear-issues:entity-census-v2', { backfillCompletedAt: new Date('2026-08-19T11:00:00.000Z'), }); mocks.listIssues.mockResolvedValue({ @@ -196,7 +258,7 @@ describe('Linear issue collection', () => { }); expect(result.stateUpdates).toContainEqual({ - collectorId: 'linear-issues:entity-census-v1', + collectorId: 'linear-issues:entity-census-v2', cursor: null, backfillCompletedAt: null, }); @@ -265,7 +327,7 @@ describe('Linear issue census', () => { done: false, pageRetirements: [ { - collectorId: 'linear-issues:entity-census-v1', + collectorId: 'linear-issues:entity-census-v2', itemId: 'deleted-issue', slug: 'linear/org/issues/deleted-issue', }, diff --git a/packages/sdk/src/server/lib/brain-linear.ts b/packages/sdk/src/server/lib/brain-linear.ts index d6caf97ff..3ec3642bb 100644 --- a/packages/sdk/src/server/lib/brain-linear.ts +++ b/packages/sdk/src/server/lib/brain-linear.ts @@ -166,6 +166,85 @@ function issueEventDate(issue: LinearBrainIssue): string { ); } +function linearIssueSlug(organizationId: string, issueId: string): string { + return `${brainNamespacePrefix('linear')}${organizationId.toLowerCase()}/issues/${issueId.toLowerCase()}`; +} + +function linearIssueLink( + organizationId: string, + issue: { id: string; identifier: string; title: string }, +): string { + return `[${issue.identifier}: ${issue.title}](${linearIssueSlug(organizationId, issue.id)})`; +} + +function relationshipLabel( + type: string, + direction: 'outbound' | 'inbound', +): string { + if (type === 'blocks') { + return direction === 'outbound' ? 'Blocks' : 'Blocked by'; + } + if (type === 'duplicate') { + return direction === 'outbound' ? 'Duplicate of' : 'Duplicated by'; + } + if (type === 'related') { + return 'Related issues'; + } + return direction === 'outbound' + ? `Related (${type})` + : `Related by (${type})`; +} + +function renderLinearMetadataLines( + organizationId: string, + issue: LinearBrainIssue, +): string[] { + const lines: string[] = []; + if (issue.startedAt) lines.push(`- **Started**: ${issue.startedAt}`); + if (issue.estimate !== null) { + lines.push(`- **Estimate**: ${issue.estimate}`); + } + if (issue.cycle) { + lines.push( + `- **Cycle**: ${issue.cycle.name ? `${issue.cycle.name} (#${issue.cycle.number})` : `#${issue.cycle.number}`}`, + ); + } + if (issue.parent) { + lines.push( + `- **Parent**: ${linearIssueLink(organizationId, issue.parent)}`, + ); + } + + const relationshipGroups = new Map< + string, + Map + >(); + for (const relationship of issue.relationships) { + const label = relationshipLabel(relationship.type, relationship.direction); + const issues = relationshipGroups.get(label) ?? new Map(); + issues.set(relationship.issue.id, relationship.issue); + relationshipGroups.set(label, issues); + } + for (const [label, relatedIssues] of [...relationshipGroups].sort( + ([a], [b]) => a.localeCompare(b), + )) { + const links = [...relatedIssues.values()] + .sort( + (a, b) => + a.identifier.localeCompare(b.identifier) || + a.title.localeCompare(b.title), + ) + .map((relatedIssue) => linearIssueLink(organizationId, relatedIssue)); + lines.push(`- **${label}**: ${links.join(', ')}`); + } + if (issue.relationshipsTruncated) { + lines.push( + '_Linear truncated the relationship list; open the source issue for the rest._', + ); + } + return lines; +} + export function buildLinearIssuePage(input: { organizationId: string; organizationName: string | null; @@ -189,6 +268,7 @@ export function buildLinearIssuePage(input: { : []; }); const description = issue.description?.trim() ?? ''; + const metadataLines = renderLinearMetadataLines(input.organizationId, issue); const content = [ ...renderBrainFrontmatter({ type: BRAIN_PAGE_TYPES.linearIssue, @@ -220,13 +300,16 @@ export function buildLinearIssuePage(input: { '', `# ${title}`, '', + ...(metadataLines.length > 0 + ? ['## Metadata', '', ...metadataLines, ''] + : []), ...(description ? [description.slice(0, ISSUE_BODY_CHAR_CAP), ''] : []), ...(discussion.length > 0 ? ['## Discussion', '', ...discussion] : []), issue.url, ].join('\n'); return { - slug: `${brainNamespacePrefix('linear')}${input.organizationId.toLowerCase()}/issues/${issue.id.toLowerCase()}`, + slug: linearIssueSlug(input.organizationId, issue.id), title, content, }; diff --git a/packages/types/src/brain.test.ts b/packages/types/src/brain.test.ts index 29116f2e5..b5bc5088f 100644 --- a/packages/types/src/brain.test.ts +++ b/packages/types/src/brain.test.ts @@ -63,7 +63,7 @@ describe('resolveBrainSourceIdForCollector', () => { resolveBrainSourceIdForCollector('github-issues:occurrence-date-v3'), ).toBe('github-issues'); expect( - resolveBrainSourceIdForCollector('linear-issues:entity-census-v1'), + resolveBrainSourceIdForCollector('linear-issues:entity-census-v2'), ).toBe('linear-issues'); }); diff --git a/packages/types/src/brain.ts b/packages/types/src/brain.ts index ab7ef916e..4baee5f80 100644 --- a/packages/types/src/brain.ts +++ b/packages/types/src/brain.ts @@ -122,7 +122,7 @@ export const BRAIN_COLLECTOR_IDS = { slackPublicChannels: 'slack-public-channels:entity-timeline-v3', discordPublicChannels: 'discord-public-channels:entity-timeline-v1', githubIssues: 'github-issues:occurrence-date-v3', - linearIssues: 'linear-issues:entity-census-v1', + linearIssues: 'linear-issues:entity-census-v2', notionPages: 'notion-pages', granolaMeetings: 'granola-meetings:entity-timeline-v3', } as const;