Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/bullmq/src/scheduled-jobs/brain-collectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
import { githubIssuesCollector } from './brain-collectors/github-issues';
import { discordPublicChannelsCollector } from './brain-collectors/discord-public-channels';
import { granolaMeetingsCollector } from './brain-collectors/granola-meetings';
import { linearIssuesCollector } from './brain-collectors/linear-issues';
import {
notionPagesCollector,
notionUsersCollector,
Expand Down Expand Up @@ -379,4 +380,5 @@ const BRAIN_COLLECTORS: BrainCollector[] = [
notionPagesCollector,
granolaMeetingsCollector,
githubIssuesCollector,
linearIssuesCollector,
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { BRAIN_COLLECTOR_IDS } from '@roomote/types';

import {
backfillBrainLinearIssuesStep,
collectBrainLinearIssues,
isBrainSourceAvailable,
} from '@roomote/sdk/server';

import type { BrainCollector } from './contracts';

/**
* Linear issues are durable product context. The SDK collector reuses the
* deployment OAuth connection, keeps comments bounded inside each issue page,
* and performs periodic complete visibility sweeps before retiring pages.
*/
export const linearIssuesCollector: BrainCollector = {
id: BRAIN_COLLECTOR_IDS.linearIssues,
displayName: 'Linear issues',
async isEnabled() {
return isBrainSourceAvailable('linear');
},
async collect({ now, limit }) {
return collectBrainLinearIssues({ now, limit });
},
async backfill({ cursor, limit }) {
return backfillBrainLinearIssuesStep({ cursor, limit });
},
};
16 changes: 15 additions & 1 deletion apps/docs/integrations/linear.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ icon: 'https://api.iconify.design/simple-icons:linear.svg?color=currentColor'

The Linear integration lets Roomote receive work from Linear, post progress back
to the issue or agent session, and keep the full run available in the Roomote
task view.
task view. When the Brain is configured, Roomote also indexes issues from the
connected workspace so agents can recall product context before opening Linear.

Linear is optional during onboarding. Connect it when your team wants Roomote to
work from issues that already have product context, acceptance criteria,
Expand Down Expand Up @@ -68,6 +69,19 @@ Roomote can post status, plan updates, and final responses back to Linear. The
Roomote task view remains the best place to inspect logs, diffs, artifacts, and
previews.

## Brain context

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.

Roomote refreshes changed issues incrementally and periodically checks the full
visible issue set. Archived issues remain available as historical context. If an
issue is deleted or the connected app can no longer see it, Roomote removes its
page only after a complete visibility check, avoiding deletion from a partial or
failed API response.

<Tip>
Put acceptance criteria and relevant repository links directly in the Linear
issue before starting agent work.
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

102 changes: 102 additions & 0 deletions packages/linear/src/__tests__/linear-client-brain.test.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/linear/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ export type {
AgentSessionEventAction,
HumanToAgentSignal,
LinearIssue,
LinearBrainIssue,
LinearBrainIssuePage,
LinearComment,
LinearUser,
AgentGuidance,
Expand Down
166 changes: 166 additions & 0 deletions packages/linear/src/linear-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
AgentSessionPlanStep,
AgentSessionUpdateResult,
LinearComment,
LinearBrainIssuePage,
LinearOrganization,
LinearViewer,
} from './types';
Expand Down Expand Up @@ -46,6 +47,171 @@ export class LinearClient {
};
}

/**
* Read one bounded issue page for durable Brain ingestion. Comments are
* fetched in the same GraphQL request so collection does not create an N+1
* request pattern.
*/
async listIssuesForBrain(input: {
first: number;
after?: string | null;
orderBy?: 'createdAt' | 'updatedAt';
createdBefore?: string | null;
updatedAfter?: string | null;
updatedBefore?: string | null;
}): Promise<LinearBrainIssuePage> {
const query = `
query BrainIssues(
$first: Int!
$after: String
$filter: IssueFilter
) {
issues(
first: $first
after: $after
orderBy: ${input.orderBy ?? 'updatedAt'}
includeArchived: true
filter: $filter
) {
nodes {
id
identifier
title
description
url
priority
priorityLabel
createdAt
updatedAt
completedAt
canceledAt
archivedAt
dueDate
state { name type }
team { key name }
project { name }
creator { name }
assignee { name }
labels { nodes { name } }
comments(last: 20, orderBy: createdAt) {
nodes {
id
body
createdAt
updatedAt
user { name }
externalUser { name }
botActor { name }
}
}
}
pageInfo { hasNextPage endCursor }
}
}
`;
const response = await this.client.client.rawRequest(query, {
first: Math.max(1, Math.min(input.first, 100)),
after: input.after ?? null,
filter:
input.createdBefore || input.updatedAfter || input.updatedBefore
? {
...(input.createdBefore
? { createdAt: { lte: input.createdBefore } }
: {}),
...(input.updatedAfter || input.updatedBefore
? {
updatedAt: {
...(input.updatedAfter
? { gte: input.updatedAfter }
: {}),
...(input.updatedBefore
? { lte: input.updatedBefore }
: {}),
},
}
: {}),
}
: null,
});
const data = response.data as {
issues?: {
nodes?: Array<{
id: string;
identifier: string;
title: string;
description?: string | null;
url: string;
priority?: number | null;
priorityLabel?: string | null;
createdAt: string;
updatedAt: string;
completedAt?: string | null;
canceledAt?: string | null;
archivedAt?: string | null;
dueDate?: string | null;
state?: { name: string; type: string } | null;
team?: { key: string; name: string } | null;
project?: { name: string } | null;
creator?: { name: string } | null;
assignee?: { name: string } | null;
labels?: { nodes?: Array<{ name: string }> };
comments?: {
nodes?: Array<{
id: string;
body: string;
createdAt: string;
updatedAt: string;
user?: { name: string } | null;
externalUser?: { name: string } | null;
botActor?: { name: string } | null;
}>;
};
}>;
pageInfo?: { hasNextPage?: boolean; endCursor?: string | null };
};
};
const connection = data.issues;

return {
issues: (connection?.nodes ?? []).map((issue) => ({
id: issue.id,
identifier: issue.identifier,
title: issue.title,
description: issue.description ?? null,
url: issue.url,
priority: issue.priority ?? null,
priorityLabel: issue.priorityLabel ?? null,
createdAt: issue.createdAt,
updatedAt: issue.updatedAt,
completedAt: issue.completedAt ?? null,
canceledAt: issue.canceledAt ?? null,
archivedAt: issue.archivedAt ?? null,
dueDate: issue.dueDate ?? null,
state: issue.state ?? null,
team: issue.team ?? null,
project: issue.project ?? null,
creator: issue.creator ?? null,
assignee: issue.assignee ?? null,
labels: (issue.labels?.nodes ?? []).map((label) => label.name),
comments: (issue.comments?.nodes ?? []).map((comment) => ({
id: comment.id,
body: comment.body,
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
author:
comment.user?.name ??
comment.externalUser?.name ??
comment.botActor?.name ??
null,
})),
})),
pageInfo: {
hasNextPage: connection?.pageInfo?.hasNextPage ?? false,
endCursor: connection?.pageInfo?.endCursor ?? null,
},
};
}

/**
* Emit an agent activity for a session.
*
Expand Down
Loading
Loading