Skip to content

Commit c11d981

Browse files
author
StackMemory Bot (CLI)
committed
feat(graphiti): add Linear-Graphiti bridge for issue graph integration
Bridge Linear webhook events to Graphiti knowledge graph, enabling automatic relationship tracking between issues and codebase context.
1 parent e053e13 commit c11d981

3 files changed

Lines changed: 369 additions & 0 deletions

File tree

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { LinearGraphitiBridge } from '../linear-graphiti-bridge.js';
3+
import type { LinearWebhookPayload } from '../../linear/webhook.js';
4+
5+
vi.mock('../../../core/monitoring/logger.js', () => ({
6+
logger: {
7+
info: vi.fn(),
8+
debug: vi.fn(),
9+
warn: vi.fn(),
10+
error: vi.fn(),
11+
},
12+
}));
13+
14+
function makePayload(
15+
overrides: Partial<LinearWebhookPayload> = {}
16+
): LinearWebhookPayload {
17+
return {
18+
action: 'create',
19+
createdAt: new Date().toISOString(),
20+
type: 'Issue',
21+
url: 'https://linear.app/test/issue/STA-1',
22+
webhookId: 'wh-1',
23+
webhookTimestamp: Date.now(),
24+
data: {
25+
id: 'issue-1',
26+
identifier: 'STA-1',
27+
title: 'Fix the bug',
28+
state: { id: 's1', name: 'In Progress', type: 'started' },
29+
priority: 2,
30+
assignee: { id: 'u1', name: 'Alice', email: 'alice@test.com' },
31+
team: { id: 't1', key: 'STA', name: 'Stack Team' },
32+
labels: [{ id: 'l1', name: 'bug', color: '#ff0000' }],
33+
updatedAt: new Date().toISOString(),
34+
},
35+
...overrides,
36+
};
37+
}
38+
39+
function mockClient(bridge: LinearGraphitiBridge) {
40+
const client = {
41+
upsertEpisode: vi.fn().mockResolvedValue({ id: 'ep-1' }),
42+
upsertEntities: vi
43+
.fn()
44+
.mockResolvedValue({ ids: ['iss-1', 'per-1', 'team-1', 'lbl-1'] }),
45+
upsertRelations: vi.fn().mockResolvedValue({ ids: ['r1', 'r2', 'r3'] }),
46+
getStatus: vi.fn(),
47+
queryTemporal: vi.fn(),
48+
};
49+
(bridge as any).client = client;
50+
return client;
51+
}
52+
53+
describe('LinearGraphitiBridge', () => {
54+
let bridge: LinearGraphitiBridge;
55+
let client: ReturnType<typeof mockClient>;
56+
57+
beforeEach(() => {
58+
bridge = new LinearGraphitiBridge({ endpoint: 'http://localhost:9999' });
59+
client = mockClient(bridge);
60+
});
61+
62+
// ── Episode creation ──
63+
64+
describe('episode creation', () => {
65+
it('creates episode for create action', async () => {
66+
await bridge.processWebhook(makePayload({ action: 'create' }));
67+
68+
expect(client.upsertEpisode).toHaveBeenCalledOnce();
69+
const ep = client.upsertEpisode.mock.calls[0][0];
70+
expect(ep.type).toBe('linear_issue_create');
71+
expect(ep.source).toBe('linear');
72+
expect(ep.content.identifier).toBe('STA-1');
73+
expect(ep.content.title).toBe('Fix the bug');
74+
});
75+
76+
it('creates episode for update action', async () => {
77+
await bridge.processWebhook(makePayload({ action: 'update' }));
78+
79+
const ep = client.upsertEpisode.mock.calls[0][0];
80+
expect(ep.type).toBe('linear_issue_update');
81+
});
82+
83+
it('creates episode for remove action', async () => {
84+
await bridge.processWebhook(makePayload({ action: 'remove' }));
85+
86+
const ep = client.upsertEpisode.mock.calls[0][0];
87+
expect(ep.type).toBe('linear_issue_remove');
88+
});
89+
});
90+
91+
// ── Entity extraction ──
92+
93+
describe('entity extraction', () => {
94+
it('upserts Issue, Person, Team, and Label entities', async () => {
95+
await bridge.processWebhook(makePayload());
96+
97+
expect(client.upsertEntities).toHaveBeenCalledOnce();
98+
const entities = client.upsertEntities.mock.calls[0][0];
99+
expect(entities).toHaveLength(4);
100+
expect(entities[0].type).toBe('Issue');
101+
expect(entities[0].name).toBe('STA-1');
102+
expect(entities[1].type).toBe('Person');
103+
expect(entities[1].name).toBe('Alice');
104+
expect(entities[2].type).toBe('Team');
105+
expect(entities[2].name).toBe('Stack Team');
106+
expect(entities[3].type).toBe('Label');
107+
expect(entities[3].name).toBe('bug');
108+
});
109+
110+
it('skips entities on remove action', async () => {
111+
await bridge.processWebhook(makePayload({ action: 'remove' }));
112+
113+
expect(client.upsertEntities).not.toHaveBeenCalled();
114+
});
115+
116+
it('handles missing assignee', async () => {
117+
const payload = makePayload();
118+
payload.data.assignee = undefined;
119+
client.upsertEntities.mockResolvedValue({
120+
ids: ['iss-1', 'team-1', 'lbl-1'],
121+
});
122+
123+
await bridge.processWebhook(payload);
124+
125+
const entities = client.upsertEntities.mock.calls[0][0];
126+
expect(entities.find((e: any) => e.type === 'Person')).toBeUndefined();
127+
});
128+
129+
it('handles missing team', async () => {
130+
const payload = makePayload();
131+
payload.data.team = undefined;
132+
client.upsertEntities.mockResolvedValue({
133+
ids: ['iss-1', 'per-1', 'lbl-1'],
134+
});
135+
136+
await bridge.processWebhook(payload);
137+
138+
const entities = client.upsertEntities.mock.calls[0][0];
139+
expect(entities.find((e: any) => e.type === 'Team')).toBeUndefined();
140+
});
141+
142+
it('handles missing labels', async () => {
143+
const payload = makePayload();
144+
payload.data.labels = undefined;
145+
client.upsertEntities.mockResolvedValue({
146+
ids: ['iss-1', 'per-1', 'team-1'],
147+
});
148+
149+
await bridge.processWebhook(payload);
150+
151+
const entities = client.upsertEntities.mock.calls[0][0];
152+
expect(entities.find((e: any) => e.type === 'Label')).toBeUndefined();
153+
});
154+
});
155+
156+
// ── Relation creation ──
157+
158+
describe('relation creation', () => {
159+
it('creates ASSIGNED_TO, BELONGS_TO, HAS_LABEL relations', async () => {
160+
await bridge.processWebhook(makePayload());
161+
162+
expect(client.upsertRelations).toHaveBeenCalledOnce();
163+
const relations = client.upsertRelations.mock.calls[0][0];
164+
expect(relations).toHaveLength(3);
165+
expect(relations[0].type).toBe('ASSIGNED_TO');
166+
expect(relations[0].fromId).toBe('iss-1');
167+
expect(relations[0].toId).toBe('per-1');
168+
expect(relations[1].type).toBe('BELONGS_TO');
169+
expect(relations[1].toId).toBe('team-1');
170+
expect(relations[2].type).toBe('HAS_LABEL');
171+
expect(relations[2].toId).toBe('lbl-1');
172+
});
173+
174+
it('skips relations on remove action', async () => {
175+
await bridge.processWebhook(makePayload({ action: 'remove' }));
176+
177+
expect(client.upsertRelations).not.toHaveBeenCalled();
178+
});
179+
180+
it('skips upsertRelations when no relations exist', async () => {
181+
const payload = makePayload();
182+
payload.data.assignee = undefined;
183+
payload.data.team = undefined;
184+
payload.data.labels = undefined;
185+
client.upsertEntities.mockResolvedValue({ ids: ['iss-1'] });
186+
187+
await bridge.processWebhook(payload);
188+
189+
expect(client.upsertRelations).not.toHaveBeenCalled();
190+
});
191+
});
192+
193+
// ── Error resilience ──
194+
195+
describe('error resilience', () => {
196+
it('catches errors without propagating', async () => {
197+
client.upsertEpisode.mockRejectedValue(new Error('network fail'));
198+
199+
// Should not throw
200+
await bridge.processWebhook(makePayload());
201+
202+
expect(client.upsertEpisode).toHaveBeenCalledOnce();
203+
});
204+
205+
it('catches entity upsert errors without propagating', async () => {
206+
client.upsertEntities.mockRejectedValue(new Error('entity fail'));
207+
208+
await bridge.processWebhook(makePayload());
209+
210+
expect(client.upsertEpisode).toHaveBeenCalledOnce();
211+
expect(client.upsertEntities).toHaveBeenCalledOnce();
212+
});
213+
});
214+
});
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/**
2+
* Linear-Graphiti Bridge
3+
* Converts Linear webhook events into Graphiti episodes, entities, and relations
4+
*/
5+
6+
import { logger } from '../../core/monitoring/logger.js';
7+
import { GraphitiClient } from './client.js';
8+
import type { Episode, EntityNode, RelationEdge } from './types.js';
9+
import type { GraphitiIntegrationConfig } from './config.js';
10+
import type { LinearWebhookPayload } from '../linear/webhook.js';
11+
12+
export class LinearGraphitiBridge {
13+
private client: GraphitiClient;
14+
15+
constructor(config: Partial<GraphitiIntegrationConfig> = {}) {
16+
this.client = new GraphitiClient(config);
17+
}
18+
19+
async processWebhook(payload: LinearWebhookPayload): Promise<void> {
20+
const { action, data } = payload;
21+
const now = Date.now();
22+
23+
try {
24+
// 1. Upsert episode
25+
const episode: Episode = {
26+
type: `linear_issue_${action}`,
27+
content: {
28+
identifier: data.identifier,
29+
title: data.title,
30+
action,
31+
state: data.state?.name,
32+
priority: data.priority,
33+
assignee: data.assignee?.name,
34+
},
35+
timestamp: now,
36+
source: 'linear',
37+
};
38+
await this.client.upsertEpisode(episode);
39+
40+
// Skip entity/relation upserts on remove
41+
if (action === 'remove') return;
42+
43+
// 2. Upsert entities
44+
const entities: EntityNode[] = [
45+
{
46+
type: 'Issue',
47+
name: data.identifier,
48+
summary: data.title,
49+
properties: {
50+
linearId: data.id,
51+
state: data.state?.name,
52+
priority: data.priority,
53+
},
54+
},
55+
];
56+
57+
if (data.assignee) {
58+
entities.push({
59+
type: 'Person',
60+
name: data.assignee.name,
61+
properties: {
62+
linearId: data.assignee.id,
63+
email: data.assignee.email,
64+
},
65+
});
66+
}
67+
68+
if (data.team) {
69+
entities.push({
70+
type: 'Team',
71+
name: data.team.name,
72+
properties: { linearId: data.team.id, key: data.team.key },
73+
});
74+
}
75+
76+
if (data.labels?.length) {
77+
for (const label of data.labels) {
78+
entities.push({
79+
type: 'Label',
80+
name: label.name,
81+
properties: { linearId: label.id, color: label.color },
82+
});
83+
}
84+
}
85+
86+
const entityResult = await this.client.upsertEntities(entities);
87+
88+
// 3. Upsert relations
89+
const issueId = entityResult.ids[0];
90+
const relations: RelationEdge[] = [];
91+
let idx = 1; // entity index after Issue
92+
93+
if (data.assignee) {
94+
relations.push({
95+
fromId: issueId,
96+
toId: entityResult.ids[idx],
97+
type: 'ASSIGNED_TO',
98+
validFrom: now,
99+
});
100+
idx++;
101+
}
102+
103+
if (data.team) {
104+
relations.push({
105+
fromId: issueId,
106+
toId: entityResult.ids[idx],
107+
type: 'BELONGS_TO',
108+
validFrom: now,
109+
});
110+
idx++;
111+
}
112+
113+
if (data.labels?.length) {
114+
for (let i = 0; i < data.labels.length; i++) {
115+
relations.push({
116+
fromId: issueId,
117+
toId: entityResult.ids[idx + i],
118+
type: 'HAS_LABEL',
119+
validFrom: now,
120+
});
121+
}
122+
}
123+
124+
if (relations.length > 0) {
125+
await this.client.upsertRelations(relations);
126+
}
127+
} catch (error) {
128+
logger.debug('Linear-Graphiti bridge error', {
129+
action,
130+
identifier: data.identifier,
131+
error: error instanceof Error ? error.message : String(error),
132+
});
133+
}
134+
}
135+
}

src/integrations/linear/webhook.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { logger } from '../../core/monitoring/logger.js';
77
import { IntegrationError, ErrorCode } from '../../core/errors/index.js';
88
import { LinearSyncEngine } from './sync.js';
99
import { LinearTaskManager } from '../../features/tasks/linear-task-manager.js';
10+
import type { LinearGraphitiBridge } from '../graphiti/linear-graphiti-bridge.js';
1011
import crypto from 'crypto';
1112
// Type-safe environment variable access
1213
function getEnv(key: string, defaultValue?: string): string {
@@ -67,6 +68,7 @@ export interface LinearWebhookPayload {
6768
export class LinearWebhookHandler {
6869
private syncEngine?: LinearSyncEngine;
6970
private taskStore?: LinearTaskManager;
71+
private graphitiBridge?: LinearGraphitiBridge;
7072
private webhookSecret?: string;
7173

7274
constructor(webhookSecret?: string) {
@@ -87,6 +89,13 @@ export class LinearWebhookHandler {
8789
this.taskStore = taskStore;
8890
}
8991

92+
/**
93+
* Set the Graphiti bridge for knowledge graph sync
94+
*/
95+
setGraphitiBridge(bridge: LinearGraphitiBridge): void {
96+
this.graphitiBridge = bridge;
97+
}
98+
9099
/**
91100
* Verify webhook signature
92101
*/
@@ -175,6 +184,17 @@ export class LinearWebhookHandler {
175184
default:
176185
logger.warn(`Unknown webhook action: ${payload.action}`);
177186
}
187+
188+
// Fire-and-forget Graphiti bridge
189+
if (this.graphitiBridge) {
190+
this.graphitiBridge.processWebhook(payload).catch((err) => {
191+
logger.debug('Linear-Graphiti bridge error', {
192+
action: payload.action,
193+
identifier: payload.data.identifier,
194+
error: err instanceof Error ? err.message : String(err),
195+
});
196+
});
197+
}
178198
}
179199

180200
/**

0 commit comments

Comments
 (0)