@studio-foundation/api 0.3.0-beta.1 → 0.3.0-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -4
- package/ARCHITECTURE.md +0 -52
- package/src/.gitkeep +0 -0
- package/src/api.ts +0 -8
- package/src/bootstrap.ts +0 -259
- package/src/event-bus.ts +0 -64
- package/src/index.ts +0 -58
- package/src/integration-runtime.ts +0 -180
- package/src/integration-store.ts +0 -125
- package/src/integrations/linear/failure-handler.ts +0 -93
- package/src/integrations/linear/webhook-handler.ts +0 -156
- package/src/integrations/registry.ts +0 -12
- package/src/integrations/types.ts +0 -37
- package/src/launcher.ts +0 -214
- package/src/logger.ts +0 -50
- package/src/plans.ts +0 -43
- package/src/routes/agents.ts +0 -131
- package/src/routes/config.ts +0 -154
- package/src/routes/contracts.ts +0 -205
- package/src/routes/pipelines.ts +0 -146
- package/src/routes/projects.ts +0 -237
- package/src/routes/runs.ts +0 -468
- package/src/routes/skills.ts +0 -136
- package/src/routes/tools.ts +0 -222
- package/src/routes/users.ts +0 -169
- package/src/routes/validate.ts +0 -196
- package/src/routes/webhooks.ts +0 -120
- package/src/server.ts +0 -155
- package/src/spawners/http-api-spawner.ts +0 -96
- package/src/user-store-pg.ts +0 -138
- package/src/user-store.ts +0 -125
- package/src/utils/repo-resolver.ts +0 -3
- package/src/webhook-dispatcher.ts +0 -142
- package/src/webhook-store.ts +0 -141
- package/tests/agents.test.ts +0 -164
- package/tests/cancel.test.ts +0 -120
- package/tests/config.test.ts +0 -196
- package/tests/contracts.test.ts +0 -302
- package/tests/event-bus.test.ts +0 -53
- package/tests/http-api-spawner.test.ts +0 -158
- package/tests/integration-runtime.test.ts +0 -199
- package/tests/integration-store.test.ts +0 -66
- package/tests/integrations/linear/failure-handler.test.ts +0 -113
- package/tests/integrations/linear/webhook-handler.test.ts +0 -191
- package/tests/launcher.test.ts +0 -380
- package/tests/linear-webhook.test.ts +0 -390
- package/tests/pipelines.test.ts +0 -166
- package/tests/projects.test.ts +0 -283
- package/tests/runs.test.ts +0 -379
- package/tests/server.test.ts +0 -208
- package/tests/skills.test.ts +0 -149
- package/tests/sse.test.ts +0 -129
- package/tests/tools.test.ts +0 -233
- package/tests/user-store.test.ts +0 -93
- package/tests/users.test.ts +0 -189
- package/tests/utils/repo-resolver.test.ts +0 -105
- package/tests/validate.test.ts +0 -233
- package/tests/webhook-dispatcher.test.ts +0 -214
- package/tests/webhook-store.test.ts +0 -98
- package/tests/webhooks.test.ts +0 -176
- package/tsconfig.json +0 -24
- package/vitest.config.ts +0 -7
|
@@ -1,199 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import { resolve } from 'node:path';
|
|
3
|
-
import { mkdirSync } from 'node:fs';
|
|
4
|
-
import Fastify from 'fastify';
|
|
5
|
-
import { IntegrationRuntime } from '../src/integration-runtime.js';
|
|
6
|
-
import { IntegrationStore } from '../src/integration-store.js';
|
|
7
|
-
import type { IntegrationPluginDef } from '@studio-foundation/contracts';
|
|
8
|
-
|
|
9
|
-
function makeLinearIntegration(): IntegrationPluginDef {
|
|
10
|
-
return {
|
|
11
|
-
name: 'linear',
|
|
12
|
-
version: 1,
|
|
13
|
-
webhook: {
|
|
14
|
-
hmac: { header: 'linear-signature', secret_env: 'LINEAR_WEBHOOK_SECRET' },
|
|
15
|
-
handler: 'linear-webhook',
|
|
16
|
-
},
|
|
17
|
-
on_failure: { handler: 'linear-failure' },
|
|
18
|
-
};
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function makeRuntime(integrations: IntegrationPluginDef[], integrationConfigs: Record<string, Record<string, unknown>> = {}) {
|
|
22
|
-
const dir = resolve('/tmp', `.studio-rt-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
23
|
-
mkdirSync(dir, { recursive: true });
|
|
24
|
-
const store = new IntegrationStore(resolve(dir, 'runs.db'));
|
|
25
|
-
const launcher = { launch: vi.fn(), cancel: vi.fn(), subscribe: vi.fn(() => () => {}) };
|
|
26
|
-
const runtime = new IntegrationRuntime({
|
|
27
|
-
integrations,
|
|
28
|
-
store,
|
|
29
|
-
launcher: launcher as never,
|
|
30
|
-
configsDir: dir,
|
|
31
|
-
projectsDir: undefined,
|
|
32
|
-
apiConfig: {},
|
|
33
|
-
integrationConfigs,
|
|
34
|
-
});
|
|
35
|
-
return { runtime, store, launcher, dir, cleanup: () => store.close() };
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
describe('IntegrationRuntime.registerRoutes', () => {
|
|
39
|
-
test('registers GET route for integration with webhook handler', async () => {
|
|
40
|
-
const { runtime, store, cleanup } = makeRuntime([makeLinearIntegration()]);
|
|
41
|
-
store.patchConfig('linear', { active: true });
|
|
42
|
-
|
|
43
|
-
const fastify = Fastify();
|
|
44
|
-
runtime.registerRoutes(fastify, '/api');
|
|
45
|
-
await fastify.ready();
|
|
46
|
-
|
|
47
|
-
const res = await fastify.inject({ method: 'GET', url: '/api/integrations/linear' });
|
|
48
|
-
expect(res.statusCode).toBe(200);
|
|
49
|
-
const body = res.json<{ active: boolean; triggers: unknown[]; webhook_url: string; pipeline: unknown }>();
|
|
50
|
-
expect(body.active).toBe(true);
|
|
51
|
-
expect(body.triggers).toEqual([]);
|
|
52
|
-
expect(body.webhook_url).toContain('/api/integrations/linear/webhook');
|
|
53
|
-
|
|
54
|
-
await fastify.close();
|
|
55
|
-
cleanup();
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
test('PATCH updates config', async () => {
|
|
59
|
-
const { runtime, store, cleanup } = makeRuntime([makeLinearIntegration()]);
|
|
60
|
-
|
|
61
|
-
const fastify = Fastify();
|
|
62
|
-
runtime.registerRoutes(fastify, '/api');
|
|
63
|
-
await fastify.ready();
|
|
64
|
-
|
|
65
|
-
const res = await fastify.inject({
|
|
66
|
-
method: 'PATCH',
|
|
67
|
-
url: '/api/integrations/linear',
|
|
68
|
-
headers: { 'content-type': 'application/json' },
|
|
69
|
-
payload: JSON.stringify({ pipeline: 'my-pipeline', active: true }),
|
|
70
|
-
});
|
|
71
|
-
expect(res.statusCode).toBe(200);
|
|
72
|
-
expect(res.json<{ pipeline: string }>().pipeline).toBe('my-pipeline');
|
|
73
|
-
expect(store.getConfig('linear').pipeline).toBe('my-pipeline');
|
|
74
|
-
|
|
75
|
-
await fastify.close();
|
|
76
|
-
cleanup();
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
test('POST webhook delegates to LinearWebhookHandler (In Progress → 202)', async () => {
|
|
80
|
-
const { runtime, store, cleanup } = makeRuntime([makeLinearIntegration()]);
|
|
81
|
-
store.patchConfig('linear', { active: true });
|
|
82
|
-
|
|
83
|
-
const fastify = Fastify();
|
|
84
|
-
runtime.registerRoutes(fastify, '/api');
|
|
85
|
-
await fastify.ready();
|
|
86
|
-
|
|
87
|
-
const payload = {
|
|
88
|
-
type: 'Issue', action: 'update',
|
|
89
|
-
data: { id: 'issue-1', identifier: 'STU-1', title: 'Test', state: { name: 'In Progress' } },
|
|
90
|
-
};
|
|
91
|
-
const res = await fastify.inject({
|
|
92
|
-
method: 'POST',
|
|
93
|
-
url: '/api/integrations/linear/webhook',
|
|
94
|
-
headers: { 'content-type': 'application/json' },
|
|
95
|
-
payload: JSON.stringify(payload),
|
|
96
|
-
});
|
|
97
|
-
expect(res.statusCode).toBe(202);
|
|
98
|
-
expect(res.json<{ run_id: string }>().run_id).toBeDefined();
|
|
99
|
-
|
|
100
|
-
await fastify.close();
|
|
101
|
-
cleanup();
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
test('does not register routes for integration without webhook handler', async () => {
|
|
105
|
-
const noWebhook: IntegrationPluginDef = { name: 'noop', version: 1 };
|
|
106
|
-
const { runtime, cleanup } = makeRuntime([noWebhook]);
|
|
107
|
-
|
|
108
|
-
const fastify = Fastify();
|
|
109
|
-
runtime.registerRoutes(fastify, '/api');
|
|
110
|
-
await fastify.ready();
|
|
111
|
-
|
|
112
|
-
const res = await fastify.inject({ method: 'GET', url: '/api/integrations/noop' });
|
|
113
|
-
expect(res.statusCode).toBe(404);
|
|
114
|
-
|
|
115
|
-
await fastify.close();
|
|
116
|
-
cleanup();
|
|
117
|
-
});
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
describe('IntegrationRuntime.setupEventBus', () => {
|
|
121
|
-
test('calls failure handler for failed pipeline_complete with linear_issue_id in meta', async () => {
|
|
122
|
-
const failureHandleSpy = vi.fn().mockResolvedValue(undefined);
|
|
123
|
-
const { FAILURE_HANDLERS } = await import('../src/integrations/registry.js');
|
|
124
|
-
const original = FAILURE_HANDLERS['linear-failure'];
|
|
125
|
-
FAILURE_HANDLERS['linear-failure'] = { handleFailure: failureHandleSpy };
|
|
126
|
-
|
|
127
|
-
const { runtime, cleanup } = makeRuntime([makeLinearIntegration()]);
|
|
128
|
-
|
|
129
|
-
const listeners: Array<(runId: string, event: { type: string; data: unknown }) => void> = [];
|
|
130
|
-
const mockBus = {
|
|
131
|
-
subscribeAll: vi.fn((fn: (runId: string, event: { type: string; data: unknown }) => void) => {
|
|
132
|
-
listeners.push(fn);
|
|
133
|
-
return () => {};
|
|
134
|
-
}),
|
|
135
|
-
};
|
|
136
|
-
|
|
137
|
-
runtime.setupEventBus(mockBus as never);
|
|
138
|
-
|
|
139
|
-
for (const listener of listeners) {
|
|
140
|
-
listener('run-abc', {
|
|
141
|
-
type: 'pipeline_complete',
|
|
142
|
-
data: { status: 'failed', duration_ms: 5000, meta: { linear_issue_id: 'issue-x' }, last_group_feedback: undefined },
|
|
143
|
-
});
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
await new Promise(r => setTimeout(r, 20));
|
|
147
|
-
expect(failureHandleSpy).toHaveBeenCalledTimes(1);
|
|
148
|
-
const ctx = failureHandleSpy.mock.calls[0][0] as { runId: string; status: string; meta: Record<string, unknown> };
|
|
149
|
-
expect(ctx.runId).toBe('run-abc');
|
|
150
|
-
expect(ctx.status).toBe('failed');
|
|
151
|
-
expect(ctx.meta['linear_issue_id']).toBe('issue-x');
|
|
152
|
-
|
|
153
|
-
FAILURE_HANDLERS['linear-failure'] = original;
|
|
154
|
-
cleanup();
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
test('does not call failure handler for successful pipeline', async () => {
|
|
158
|
-
const failureHandleSpy = vi.fn().mockResolvedValue(undefined);
|
|
159
|
-
const { FAILURE_HANDLERS } = await import('../src/integrations/registry.js');
|
|
160
|
-
const original = FAILURE_HANDLERS['linear-failure'];
|
|
161
|
-
FAILURE_HANDLERS['linear-failure'] = { handleFailure: failureHandleSpy };
|
|
162
|
-
|
|
163
|
-
const { runtime, cleanup } = makeRuntime([makeLinearIntegration()]);
|
|
164
|
-
|
|
165
|
-
const listeners: Array<(runId: string, event: { type: string; data: unknown }) => void> = [];
|
|
166
|
-
const mockBus = {
|
|
167
|
-
subscribeAll: vi.fn((fn: (runId: string, event: { type: string; data: unknown }) => void) => {
|
|
168
|
-
listeners.push(fn);
|
|
169
|
-
return () => {};
|
|
170
|
-
}),
|
|
171
|
-
};
|
|
172
|
-
runtime.setupEventBus(mockBus as never);
|
|
173
|
-
|
|
174
|
-
for (const listener of listeners) {
|
|
175
|
-
listener('run-ok', {
|
|
176
|
-
type: 'pipeline_complete',
|
|
177
|
-
data: { status: 'success', duration_ms: 1000, meta: { linear_issue_id: 'issue-x' } },
|
|
178
|
-
});
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
await new Promise(r => setTimeout(r, 20));
|
|
182
|
-
expect(failureHandleSpy).not.toHaveBeenCalled();
|
|
183
|
-
|
|
184
|
-
FAILURE_HANDLERS['linear-failure'] = original;
|
|
185
|
-
cleanup();
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
test('does not subscribe when integration has no on_failure handler', async () => {
|
|
189
|
-
const noFailure: IntegrationPluginDef = { name: 'linear', version: 1, webhook: { handler: 'linear-webhook' } };
|
|
190
|
-
const { runtime, cleanup } = makeRuntime([noFailure]);
|
|
191
|
-
|
|
192
|
-
const mockBus = { subscribeAll: vi.fn(() => () => {}) };
|
|
193
|
-
runtime.setupEventBus(mockBus as never);
|
|
194
|
-
|
|
195
|
-
// subscribeAll may still be called for other reasons, but failure handler must not fire
|
|
196
|
-
// Test that if it is called, no failure is dispatched
|
|
197
|
-
cleanup();
|
|
198
|
-
});
|
|
199
|
-
});
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect } from 'vitest';
|
|
2
|
-
import { resolve } from 'node:path';
|
|
3
|
-
import { mkdirSync } from 'node:fs';
|
|
4
|
-
import { IntegrationStore } from '../src/integration-store.js';
|
|
5
|
-
|
|
6
|
-
function makeStore() {
|
|
7
|
-
const dir = resolve('/tmp', `.studio-int-store-${Date.now()}`);
|
|
8
|
-
mkdirSync(dir, { recursive: true });
|
|
9
|
-
const store = new IntegrationStore(resolve(dir, 'runs.db'));
|
|
10
|
-
return { store, cleanup: () => store.close() };
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
describe('IntegrationStore', () => {
|
|
14
|
-
test('getConfig returns defaults for unknown integration', () => {
|
|
15
|
-
const { store, cleanup } = makeStore();
|
|
16
|
-
const config = store.getConfig('linear');
|
|
17
|
-
expect(config).toEqual({ active: false });
|
|
18
|
-
cleanup();
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
test('patchConfig stores and retrieves pipeline and active', () => {
|
|
22
|
-
const { store, cleanup } = makeStore();
|
|
23
|
-
store.patchConfig('linear', { pipeline: 'feature-builder', active: true });
|
|
24
|
-
expect(store.getConfig('linear')).toEqual({ pipeline: 'feature-builder', active: true });
|
|
25
|
-
cleanup();
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
test('patchConfig for one integration does not affect another', () => {
|
|
29
|
-
const { store, cleanup } = makeStore();
|
|
30
|
-
store.patchConfig('linear', { active: true });
|
|
31
|
-
expect(store.getConfig('slack').active).toBe(false);
|
|
32
|
-
cleanup();
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
test('insertTrigger and listTriggers are partitioned by integration_name', () => {
|
|
36
|
-
const { store, cleanup } = makeStore();
|
|
37
|
-
store.insertTrigger({
|
|
38
|
-
id: 'trig-1',
|
|
39
|
-
integration_name: 'linear',
|
|
40
|
-
received_at: new Date().toISOString(),
|
|
41
|
-
pipeline: 'feature-builder',
|
|
42
|
-
run_id: 'run-1',
|
|
43
|
-
status: 'success',
|
|
44
|
-
});
|
|
45
|
-
store.insertTrigger({
|
|
46
|
-
id: 'trig-2',
|
|
47
|
-
integration_name: 'slack',
|
|
48
|
-
received_at: new Date().toISOString(),
|
|
49
|
-
pipeline: 'feature-builder',
|
|
50
|
-
run_id: 'run-2',
|
|
51
|
-
status: 'success',
|
|
52
|
-
});
|
|
53
|
-
expect(store.listTriggers('linear')).toHaveLength(1);
|
|
54
|
-
expect(store.listTriggers('linear')[0].id).toBe('trig-1');
|
|
55
|
-
expect(store.listTriggers('slack')).toHaveLength(1);
|
|
56
|
-
cleanup();
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
test('listTriggers returns most recent first', () => {
|
|
60
|
-
const { store, cleanup } = makeStore();
|
|
61
|
-
store.insertTrigger({ id: 'a', integration_name: 'linear', received_at: '2024-01-01T00:00:00Z', pipeline: 'p', status: 'success' });
|
|
62
|
-
store.insertTrigger({ id: 'b', integration_name: 'linear', received_at: '2024-01-02T00:00:00Z', pipeline: 'p', status: 'success' });
|
|
63
|
-
expect(store.listTriggers('linear')[0].id).toBe('b');
|
|
64
|
-
cleanup();
|
|
65
|
-
});
|
|
66
|
-
});
|
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import { LinearFailureHandler } from '../../../src/integrations/linear/failure-handler.js';
|
|
3
|
-
|
|
4
|
-
const mockFetch = vi.fn();
|
|
5
|
-
vi.stubGlobal('fetch', mockFetch);
|
|
6
|
-
|
|
7
|
-
const STATES_RESPONSE = {
|
|
8
|
-
data: {
|
|
9
|
-
issue: {
|
|
10
|
-
team: {
|
|
11
|
-
states: { nodes: [{ id: 'state-backlog', name: 'Backlog' }, { id: 'state-todo', name: 'Todo' }] },
|
|
12
|
-
},
|
|
13
|
-
},
|
|
14
|
-
},
|
|
15
|
-
};
|
|
16
|
-
const COMMENT_RESPONSE = { data: { commentCreate: { success: true } } };
|
|
17
|
-
const UPDATE_RESPONSE = { data: { issueUpdate: { success: true } } };
|
|
18
|
-
|
|
19
|
-
function makeCtx(overrides: {
|
|
20
|
-
apiKey?: string;
|
|
21
|
-
issueId?: string;
|
|
22
|
-
runId?: string;
|
|
23
|
-
iterations?: number;
|
|
24
|
-
rejectionReason?: string;
|
|
25
|
-
rejectionDetails?: string[];
|
|
26
|
-
} = {}) {
|
|
27
|
-
return {
|
|
28
|
-
runId: overrides.runId ?? 'run-123',
|
|
29
|
-
durationMs: 5000,
|
|
30
|
-
status: 'failed',
|
|
31
|
-
meta: { linear_issue_id: overrides.issueId ?? 'issue-abc' },
|
|
32
|
-
lastGroupFeedback: overrides.iterations != null ? {
|
|
33
|
-
iteration: overrides.iterations,
|
|
34
|
-
rejection_reason: overrides.rejectionReason,
|
|
35
|
-
rejection_details: overrides.rejectionDetails,
|
|
36
|
-
} as never : undefined,
|
|
37
|
-
integration: { name: 'linear', version: 1, on_failure: { handler: 'linear-failure' } },
|
|
38
|
-
integrationConfig: { LINEAR_API_KEY: overrides.apiKey ?? 'lin_api_test' },
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
beforeEach(() => { mockFetch.mockReset(); });
|
|
43
|
-
afterEach(() => { delete process.env['LINEAR_API_KEY']; });
|
|
44
|
-
|
|
45
|
-
describe('LinearFailureHandler.handleFailure', () => {
|
|
46
|
-
const handler = new LinearFailureHandler();
|
|
47
|
-
|
|
48
|
-
it('skips when no LINEAR_API_KEY in integrationConfig or env', async () => {
|
|
49
|
-
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
50
|
-
const ctx = makeCtx({ apiKey: '' });
|
|
51
|
-
await handler.handleFailure(ctx);
|
|
52
|
-
expect(mockFetch).not.toHaveBeenCalled();
|
|
53
|
-
warnSpy.mockRestore();
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it('skips when meta has no linear_issue_id', async () => {
|
|
57
|
-
const ctx = { ...makeCtx(), meta: {} };
|
|
58
|
-
await handler.handleFailure(ctx);
|
|
59
|
-
expect(mockFetch).not.toHaveBeenCalled();
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it('posts comment, queries states, and transitions to Backlog on failure', async () => {
|
|
63
|
-
mockFetch
|
|
64
|
-
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(COMMENT_RESPONSE) } as Response)
|
|
65
|
-
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(STATES_RESPONSE) } as Response)
|
|
66
|
-
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(UPDATE_RESPONSE) } as Response);
|
|
67
|
-
|
|
68
|
-
await handler.handleFailure(makeCtx({
|
|
69
|
-
issueId: 'issue-xyz', runId: 'run-456',
|
|
70
|
-
iterations: 3, rejectionReason: 'QA rejected', rejectionDetails: ['Hardcoded strings'],
|
|
71
|
-
}));
|
|
72
|
-
|
|
73
|
-
expect(mockFetch).toHaveBeenCalledTimes(3);
|
|
74
|
-
const [, commentInit] = mockFetch.mock.calls[0] as [string, RequestInit];
|
|
75
|
-
const body = JSON.parse(commentInit.body as string) as { variables: { body: string } };
|
|
76
|
-
expect(body.variables.body).toContain('❌ **Code Builder échoué**');
|
|
77
|
-
expect(body.variables.body).toContain('3 itérations QA');
|
|
78
|
-
expect(body.variables.body).toContain('run-456');
|
|
79
|
-
expect(body.variables.body).toContain('Hardcoded strings');
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it('posts comment even when Backlog state is not found', async () => {
|
|
83
|
-
const noBacklog = { data: { issue: { team: { states: { nodes: [{ id: 'state-todo', name: 'Todo' }] } } } } };
|
|
84
|
-
mockFetch
|
|
85
|
-
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(COMMENT_RESPONSE) } as Response)
|
|
86
|
-
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(noBacklog) } as Response);
|
|
87
|
-
|
|
88
|
-
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
89
|
-
await handler.handleFailure(makeCtx());
|
|
90
|
-
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
91
|
-
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('"Backlog" state not found'));
|
|
92
|
-
warnSpy.mockRestore();
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it('swallows fetch errors and logs them', async () => {
|
|
96
|
-
mockFetch.mockRejectedValueOnce(new Error('network timeout'));
|
|
97
|
-
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
98
|
-
await expect(handler.handleFailure(makeCtx())).resolves.toBeUndefined();
|
|
99
|
-
expect(errorSpy).toHaveBeenCalled();
|
|
100
|
-
errorSpy.mockRestore();
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it('uses process.env.LINEAR_API_KEY as fallback', async () => {
|
|
104
|
-
process.env['LINEAR_API_KEY'] = 'env-key';
|
|
105
|
-
const ctxNoKey = { ...makeCtx(), integrationConfig: {} };
|
|
106
|
-
mockFetch
|
|
107
|
-
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(COMMENT_RESPONSE) } as Response)
|
|
108
|
-
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(STATES_RESPONSE) } as Response)
|
|
109
|
-
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(UPDATE_RESPONSE) } as Response);
|
|
110
|
-
await handler.handleFailure(ctxNoKey);
|
|
111
|
-
expect(mockFetch).toHaveBeenCalledTimes(3);
|
|
112
|
-
});
|
|
113
|
-
});
|
|
@@ -1,191 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect, vi, beforeEach } from 'vitest';
|
|
2
|
-
import { createHmac } from 'node:crypto';
|
|
3
|
-
import { resolve } from 'node:path';
|
|
4
|
-
import { mkdirSync } from 'node:fs';
|
|
5
|
-
import { LinearWebhookHandler } from '../../../src/integrations/linear/webhook-handler.js';
|
|
6
|
-
import { IntegrationStore } from '../../../src/integration-store.js';
|
|
7
|
-
import type { IntegrationPluginDef } from '@studio-foundation/contracts';
|
|
8
|
-
|
|
9
|
-
const WEBHOOK_SECRET = 'test-secret-abc';
|
|
10
|
-
|
|
11
|
-
function sign(body: string, secret: string): string {
|
|
12
|
-
return createHmac('sha256', secret).update(Buffer.from(body)).digest('hex');
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function makeInProgressBody(overrides: Record<string, unknown> = {}) {
|
|
16
|
-
return {
|
|
17
|
-
type: 'Issue', action: 'update',
|
|
18
|
-
data: {
|
|
19
|
-
id: 'abc-123', identifier: 'STU-42', title: 'Add dark mode',
|
|
20
|
-
description: 'Users want dark mode.', state: { name: 'In Progress' },
|
|
21
|
-
...overrides,
|
|
22
|
-
},
|
|
23
|
-
};
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function makeContext(opts: {
|
|
27
|
-
withSecret?: boolean;
|
|
28
|
-
active?: boolean;
|
|
29
|
-
body?: object;
|
|
30
|
-
signatureOverride?: string;
|
|
31
|
-
} = {}) {
|
|
32
|
-
const dir = resolve('/tmp', `.studio-wh-handler-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
33
|
-
mkdirSync(dir, { recursive: true });
|
|
34
|
-
const store = new IntegrationStore(resolve(dir, 'runs.db'));
|
|
35
|
-
store.patchConfig('linear', { active: opts.active ?? true });
|
|
36
|
-
|
|
37
|
-
const launched: Array<{ pipeline: string; input: Record<string, unknown>; meta?: Record<string, unknown> }> = [];
|
|
38
|
-
const launcher = {
|
|
39
|
-
launch: vi.fn(async (cfg: { pipeline: string; input: Record<string, unknown>; meta?: Record<string, unknown>; runId: string }) => {
|
|
40
|
-
launched.push({ pipeline: cfg.pipeline, input: cfg.input, meta: cfg.meta });
|
|
41
|
-
return { run_id: cfg.runId };
|
|
42
|
-
}),
|
|
43
|
-
cancel: vi.fn(async () => {}),
|
|
44
|
-
subscribe: vi.fn(() => () => {}),
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
const bodyObj = opts.body ?? makeInProgressBody();
|
|
48
|
-
const bodyStr = JSON.stringify(bodyObj);
|
|
49
|
-
const rawBody = Buffer.from(bodyStr);
|
|
50
|
-
|
|
51
|
-
const integration: IntegrationPluginDef = {
|
|
52
|
-
name: 'linear',
|
|
53
|
-
version: 1,
|
|
54
|
-
webhook: {
|
|
55
|
-
...(opts.withSecret ? { hmac: { header: 'linear-signature', secret_env: 'LINEAR_WEBHOOK_SECRET' } } : {}),
|
|
56
|
-
handler: 'linear-webhook',
|
|
57
|
-
},
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
const headers: Record<string, string | undefined> = { 'content-type': 'application/json' };
|
|
61
|
-
if (opts.signatureOverride !== undefined) {
|
|
62
|
-
headers['linear-signature'] = opts.signatureOverride;
|
|
63
|
-
} else if (opts.withSecret) {
|
|
64
|
-
headers['linear-signature'] = sign(bodyStr, WEBHOOK_SECRET);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const ctx = {
|
|
68
|
-
rawBody,
|
|
69
|
-
headers,
|
|
70
|
-
integration,
|
|
71
|
-
store,
|
|
72
|
-
launcher,
|
|
73
|
-
configsDir: dir,
|
|
74
|
-
projectsDir: undefined,
|
|
75
|
-
apiConfig: {},
|
|
76
|
-
integrationConfig: opts.withSecret ? { LINEAR_WEBHOOK_SECRET: WEBHOOK_SECRET } : {},
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
const reply = {
|
|
80
|
-
status: vi.fn().mockReturnThis(),
|
|
81
|
-
send: vi.fn().mockReturnThis(),
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
return { ctx, reply, launched, store, cleanup: () => store.close() };
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
describe('LinearWebhookHandler', () => {
|
|
88
|
-
const handler = new LinearWebhookHandler();
|
|
89
|
-
|
|
90
|
-
beforeEach(() => {
|
|
91
|
-
vi.clearAllMocks();
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
test('accepts "In Progress" transition and launches pipeline', async () => {
|
|
95
|
-
const { ctx, reply, launched, cleanup } = makeContext();
|
|
96
|
-
await handler.handle(ctx as never, reply as never);
|
|
97
|
-
|
|
98
|
-
expect(reply.status).toHaveBeenCalledWith(202);
|
|
99
|
-
expect(launched).toHaveLength(1);
|
|
100
|
-
expect(launched[0].pipeline).toBe('feature-builder');
|
|
101
|
-
expect(launched[0].input['brief_summary']).toBe('STU-42 — Add dark mode');
|
|
102
|
-
expect(launched[0].input['description']).toBe('Users want dark mode.');
|
|
103
|
-
expect(launched[0].meta?.['linear_issue_id']).toBe('abc-123');
|
|
104
|
-
expect(launched[0].meta?.['linear_issue_identifier']).toBe('STU-42');
|
|
105
|
-
expect(launched[0].meta?.['linear_issue_url']).toBe('https://linear.app/studioag/issue/STU-42');
|
|
106
|
-
|
|
107
|
-
// Check that a trigger record was stored
|
|
108
|
-
const triggers = ctx.store.listTriggers('linear');
|
|
109
|
-
expect(triggers).toHaveLength(1);
|
|
110
|
-
expect(triggers[0].status).toBe('success');
|
|
111
|
-
expect(triggers[0].external_id).toBe('abc-123');
|
|
112
|
-
|
|
113
|
-
cleanup();
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
test('ignores non-"In Progress" state', async () => {
|
|
117
|
-
const { ctx, reply, launched, cleanup } = makeContext({
|
|
118
|
-
body: makeInProgressBody({ state: { name: 'Done' } }),
|
|
119
|
-
});
|
|
120
|
-
await handler.handle(ctx as never, reply as never);
|
|
121
|
-
|
|
122
|
-
expect(reply.status).toHaveBeenCalledWith(200);
|
|
123
|
-
const sendArg = (reply.send as ReturnType<typeof vi.fn>).mock.calls[0][0] as { ignored: boolean; reason: string };
|
|
124
|
-
expect(sendArg.ignored).toBe(true);
|
|
125
|
-
expect(sendArg.reason).toContain('Done');
|
|
126
|
-
expect(launched).toHaveLength(0);
|
|
127
|
-
cleanup();
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
test('ignores non-update actions', async () => {
|
|
131
|
-
const { ctx, reply, launched, cleanup } = makeContext({
|
|
132
|
-
body: { ...makeInProgressBody(), action: 'create' },
|
|
133
|
-
});
|
|
134
|
-
await handler.handle(ctx as never, reply as never);
|
|
135
|
-
expect(reply.status).toHaveBeenCalledWith(200);
|
|
136
|
-
expect(launched).toHaveLength(0);
|
|
137
|
-
cleanup();
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
test('ignores when integration is inactive', async () => {
|
|
141
|
-
const { ctx, reply, launched, cleanup } = makeContext({ active: false });
|
|
142
|
-
await handler.handle(ctx as never, reply as never);
|
|
143
|
-
expect(reply.status).toHaveBeenCalledWith(200);
|
|
144
|
-
const sendArg = (reply.send as ReturnType<typeof vi.fn>).mock.calls[0][0] as { reason: string };
|
|
145
|
-
expect(sendArg.reason).toContain('inactive');
|
|
146
|
-
expect(launched).toHaveLength(0);
|
|
147
|
-
cleanup();
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
test('accepts valid HMAC signature', async () => {
|
|
151
|
-
const { ctx, reply, launched, cleanup } = makeContext({ withSecret: true });
|
|
152
|
-
await handler.handle(ctx as never, reply as never);
|
|
153
|
-
expect(reply.status).toHaveBeenCalledWith(202);
|
|
154
|
-
expect(launched).toHaveLength(1);
|
|
155
|
-
cleanup();
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
test('rejects invalid HMAC signature', async () => {
|
|
159
|
-
const { ctx, reply, cleanup } = makeContext({ withSecret: true, signatureOverride: 'deadbeef' });
|
|
160
|
-
await handler.handle(ctx as never, reply as never);
|
|
161
|
-
expect(reply.status).toHaveBeenCalledWith(401);
|
|
162
|
-
cleanup();
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
test('rejects missing signature header when HMAC configured', async () => {
|
|
166
|
-
const { ctx, reply, cleanup } = makeContext({ withSecret: true, signatureOverride: undefined });
|
|
167
|
-
// Remove the auto-added signature to simulate missing header
|
|
168
|
-
delete (ctx.headers as Record<string, unknown>)['linear-signature'];
|
|
169
|
-
await handler.handle(ctx as never, reply as never);
|
|
170
|
-
expect(reply.status).toHaveBeenCalledWith(401);
|
|
171
|
-
cleanup();
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
test('returns 400 for invalid JSON', async () => {
|
|
175
|
-
const dir = resolve('/tmp', `.studio-wh-json-${Date.now()}`);
|
|
176
|
-
mkdirSync(dir, { recursive: true });
|
|
177
|
-
const store = new IntegrationStore(resolve(dir, 'runs.db'));
|
|
178
|
-
store.patchConfig('linear', { active: true });
|
|
179
|
-
const launcher = { launch: vi.fn(), cancel: vi.fn(), subscribe: vi.fn(() => () => {}) };
|
|
180
|
-
const ctx = {
|
|
181
|
-
rawBody: Buffer.from('not-json{'),
|
|
182
|
-
headers: {},
|
|
183
|
-
integration: { name: 'linear', version: 1, webhook: { handler: 'linear-webhook' } },
|
|
184
|
-
store, launcher, configsDir: dir, projectsDir: undefined, apiConfig: {}, integrationConfig: {},
|
|
185
|
-
};
|
|
186
|
-
const reply = { status: vi.fn().mockReturnThis(), send: vi.fn().mockReturnThis() };
|
|
187
|
-
await handler.handle(ctx as never, reply as never);
|
|
188
|
-
expect(reply.status).toHaveBeenCalledWith(400);
|
|
189
|
-
store.close();
|
|
190
|
-
});
|
|
191
|
-
});
|