@studio-foundation/api 0.3.0-beta.1

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.
Files changed (188) hide show
  1. package/ARCHITECTURE.md +52 -0
  2. package/LICENSE +663 -0
  3. package/README.md +161 -0
  4. package/dist/api.d.ts +8 -0
  5. package/dist/api.d.ts.map +1 -0
  6. package/dist/api.js +6 -0
  7. package/dist/api.js.map +1 -0
  8. package/dist/bootstrap.d.ts +66 -0
  9. package/dist/bootstrap.d.ts.map +1 -0
  10. package/dist/bootstrap.js +193 -0
  11. package/dist/bootstrap.js.map +1 -0
  12. package/dist/event-bus.d.ts +16 -0
  13. package/dist/event-bus.d.ts.map +1 -0
  14. package/dist/event-bus.js +37 -0
  15. package/dist/event-bus.js.map +1 -0
  16. package/dist/index.d.ts +3 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +52 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/integration-runtime.d.ts +22 -0
  21. package/dist/integration-runtime.d.ts.map +1 -0
  22. package/dist/integration-runtime.js +150 -0
  23. package/dist/integration-runtime.js.map +1 -0
  24. package/dist/integration-store.d.ts +26 -0
  25. package/dist/integration-store.d.ts.map +1 -0
  26. package/dist/integration-store.js +80 -0
  27. package/dist/integration-store.js.map +1 -0
  28. package/dist/integrations/linear/failure-handler.d.ts +5 -0
  29. package/dist/integrations/linear/failure-handler.d.ts.map +1 -0
  30. package/dist/integrations/linear/failure-handler.js +73 -0
  31. package/dist/integrations/linear/failure-handler.js.map +1 -0
  32. package/dist/integrations/linear/webhook-handler.d.ts +6 -0
  33. package/dist/integrations/linear/webhook-handler.d.ts.map +1 -0
  34. package/dist/integrations/linear/webhook-handler.js +131 -0
  35. package/dist/integrations/linear/webhook-handler.js.map +1 -0
  36. package/dist/integrations/registry.d.ts +4 -0
  37. package/dist/integrations/registry.d.ts.map +1 -0
  38. package/dist/integrations/registry.js +9 -0
  39. package/dist/integrations/registry.js.map +1 -0
  40. package/dist/integrations/types.d.ts +33 -0
  41. package/dist/integrations/types.d.ts.map +1 -0
  42. package/dist/integrations/types.js +2 -0
  43. package/dist/integrations/types.js.map +1 -0
  44. package/dist/launcher.d.ts +45 -0
  45. package/dist/launcher.d.ts.map +1 -0
  46. package/dist/launcher.js +149 -0
  47. package/dist/launcher.js.map +1 -0
  48. package/dist/logger.d.ts +7 -0
  49. package/dist/logger.d.ts.map +1 -0
  50. package/dist/logger.js +37 -0
  51. package/dist/logger.js.map +1 -0
  52. package/dist/plans.d.ts +13 -0
  53. package/dist/plans.d.ts.map +1 -0
  54. package/dist/plans.js +33 -0
  55. package/dist/plans.js.map +1 -0
  56. package/dist/routes/agents.d.ts +6 -0
  57. package/dist/routes/agents.d.ts.map +1 -0
  58. package/dist/routes/agents.js +118 -0
  59. package/dist/routes/agents.js.map +1 -0
  60. package/dist/routes/config.d.ts +6 -0
  61. package/dist/routes/config.d.ts.map +1 -0
  62. package/dist/routes/config.js +129 -0
  63. package/dist/routes/config.js.map +1 -0
  64. package/dist/routes/contracts.d.ts +6 -0
  65. package/dist/routes/contracts.d.ts.map +1 -0
  66. package/dist/routes/contracts.js +188 -0
  67. package/dist/routes/contracts.js.map +1 -0
  68. package/dist/routes/pipelines.d.ts +6 -0
  69. package/dist/routes/pipelines.d.ts.map +1 -0
  70. package/dist/routes/pipelines.js +133 -0
  71. package/dist/routes/pipelines.js.map +1 -0
  72. package/dist/routes/projects.d.ts +6 -0
  73. package/dist/routes/projects.d.ts.map +1 -0
  74. package/dist/routes/projects.js +220 -0
  75. package/dist/routes/projects.js.map +1 -0
  76. package/dist/routes/runs.d.ts +6 -0
  77. package/dist/routes/runs.d.ts.map +1 -0
  78. package/dist/routes/runs.js +427 -0
  79. package/dist/routes/runs.js.map +1 -0
  80. package/dist/routes/skills.d.ts +6 -0
  81. package/dist/routes/skills.d.ts.map +1 -0
  82. package/dist/routes/skills.js +123 -0
  83. package/dist/routes/skills.js.map +1 -0
  84. package/dist/routes/tools.d.ts +6 -0
  85. package/dist/routes/tools.d.ts.map +1 -0
  86. package/dist/routes/tools.js +193 -0
  87. package/dist/routes/tools.js.map +1 -0
  88. package/dist/routes/users.d.ts +6 -0
  89. package/dist/routes/users.d.ts.map +1 -0
  90. package/dist/routes/users.js +152 -0
  91. package/dist/routes/users.js.map +1 -0
  92. package/dist/routes/validate.d.ts +6 -0
  93. package/dist/routes/validate.d.ts.map +1 -0
  94. package/dist/routes/validate.js +171 -0
  95. package/dist/routes/validate.js.map +1 -0
  96. package/dist/routes/webhooks.d.ts +8 -0
  97. package/dist/routes/webhooks.d.ts.map +1 -0
  98. package/dist/routes/webhooks.js +102 -0
  99. package/dist/routes/webhooks.js.map +1 -0
  100. package/dist/server.d.ts +46 -0
  101. package/dist/server.d.ts.map +1 -0
  102. package/dist/server.js +101 -0
  103. package/dist/server.js.map +1 -0
  104. package/dist/spawners/http-api-spawner.d.ts +10 -0
  105. package/dist/spawners/http-api-spawner.d.ts.map +1 -0
  106. package/dist/spawners/http-api-spawner.js +88 -0
  107. package/dist/spawners/http-api-spawner.js.map +1 -0
  108. package/dist/user-store-pg.d.ts +21 -0
  109. package/dist/user-store-pg.d.ts.map +1 -0
  110. package/dist/user-store-pg.js +97 -0
  111. package/dist/user-store-pg.js.map +1 -0
  112. package/dist/user-store.d.ts +29 -0
  113. package/dist/user-store.d.ts.map +1 -0
  114. package/dist/user-store.js +97 -0
  115. package/dist/user-store.js.map +1 -0
  116. package/dist/utils/repo-resolver.d.ts +3 -0
  117. package/dist/utils/repo-resolver.d.ts.map +1 -0
  118. package/dist/utils/repo-resolver.js +3 -0
  119. package/dist/utils/repo-resolver.js.map +1 -0
  120. package/dist/webhook-dispatcher.d.ts +13 -0
  121. package/dist/webhook-dispatcher.d.ts.map +1 -0
  122. package/dist/webhook-dispatcher.js +108 -0
  123. package/dist/webhook-dispatcher.js.map +1 -0
  124. package/dist/webhook-store.d.ts +31 -0
  125. package/dist/webhook-store.d.ts.map +1 -0
  126. package/dist/webhook-store.js +91 -0
  127. package/dist/webhook-store.js.map +1 -0
  128. package/package.json +59 -0
  129. package/src/.gitkeep +0 -0
  130. package/src/api.ts +8 -0
  131. package/src/bootstrap.ts +259 -0
  132. package/src/event-bus.ts +64 -0
  133. package/src/index.ts +58 -0
  134. package/src/integration-runtime.ts +180 -0
  135. package/src/integration-store.ts +125 -0
  136. package/src/integrations/linear/failure-handler.ts +93 -0
  137. package/src/integrations/linear/webhook-handler.ts +156 -0
  138. package/src/integrations/registry.ts +12 -0
  139. package/src/integrations/types.ts +37 -0
  140. package/src/launcher.ts +214 -0
  141. package/src/logger.ts +50 -0
  142. package/src/plans.ts +43 -0
  143. package/src/routes/agents.ts +131 -0
  144. package/src/routes/config.ts +154 -0
  145. package/src/routes/contracts.ts +205 -0
  146. package/src/routes/pipelines.ts +146 -0
  147. package/src/routes/projects.ts +237 -0
  148. package/src/routes/runs.ts +468 -0
  149. package/src/routes/skills.ts +136 -0
  150. package/src/routes/tools.ts +222 -0
  151. package/src/routes/users.ts +169 -0
  152. package/src/routes/validate.ts +196 -0
  153. package/src/routes/webhooks.ts +120 -0
  154. package/src/server.ts +155 -0
  155. package/src/spawners/http-api-spawner.ts +96 -0
  156. package/src/user-store-pg.ts +138 -0
  157. package/src/user-store.ts +125 -0
  158. package/src/utils/repo-resolver.ts +3 -0
  159. package/src/webhook-dispatcher.ts +142 -0
  160. package/src/webhook-store.ts +141 -0
  161. package/tests/agents.test.ts +164 -0
  162. package/tests/cancel.test.ts +120 -0
  163. package/tests/config.test.ts +196 -0
  164. package/tests/contracts.test.ts +302 -0
  165. package/tests/event-bus.test.ts +53 -0
  166. package/tests/http-api-spawner.test.ts +158 -0
  167. package/tests/integration-runtime.test.ts +199 -0
  168. package/tests/integration-store.test.ts +66 -0
  169. package/tests/integrations/linear/failure-handler.test.ts +113 -0
  170. package/tests/integrations/linear/webhook-handler.test.ts +191 -0
  171. package/tests/launcher.test.ts +380 -0
  172. package/tests/linear-webhook.test.ts +390 -0
  173. package/tests/pipelines.test.ts +166 -0
  174. package/tests/projects.test.ts +283 -0
  175. package/tests/runs.test.ts +379 -0
  176. package/tests/server.test.ts +208 -0
  177. package/tests/skills.test.ts +149 -0
  178. package/tests/sse.test.ts +129 -0
  179. package/tests/tools.test.ts +233 -0
  180. package/tests/user-store.test.ts +93 -0
  181. package/tests/users.test.ts +189 -0
  182. package/tests/utils/repo-resolver.test.ts +105 -0
  183. package/tests/validate.test.ts +233 -0
  184. package/tests/webhook-dispatcher.test.ts +214 -0
  185. package/tests/webhook-store.test.ts +98 -0
  186. package/tests/webhooks.test.ts +176 -0
  187. package/tsconfig.json +24 -0
  188. package/vitest.config.ts +7 -0
@@ -0,0 +1,390 @@
1
+ import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { createHmac } from 'node:crypto';
3
+ import { resolve } from 'node:path';
4
+ import { mkdirSync } from 'node:fs';
5
+ import { buildServer } from '../src/server.js';
6
+ import { InMemoryRunStore } from '@studio-foundation/engine';
7
+ import { WebhookStore } from '../src/webhook-store.js';
8
+ import { IntegrationStore } from '../src/integration-store.js';
9
+ import { IntegrationRuntime } from '../src/integration-runtime.js';
10
+ import type { IntegrationPluginDef } from '@studio-foundation/contracts';
11
+
12
+ const WEBHOOK_SECRET = 'test-whsec-abc123';
13
+
14
+ const linearIntegration: IntegrationPluginDef = {
15
+ name: 'linear',
16
+ version: 1,
17
+ webhook: {
18
+ hmac: {
19
+ header: 'linear-signature',
20
+ secret_env: 'LINEAR_WEBHOOK_SECRET',
21
+ },
22
+ handler: 'linear-webhook',
23
+ },
24
+ on_failure: {
25
+ handler: 'linear-failure',
26
+ },
27
+ };
28
+
29
+ function sign(body: string, secret: string): string {
30
+ return createHmac('sha256', secret).update(Buffer.from(body)).digest('hex');
31
+ }
32
+
33
+ function makeServer(opts: { withSecret?: boolean; withApiKey?: boolean; active?: boolean } = {}) {
34
+ const dir = resolve('/tmp', `.studio-linear-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
35
+ mkdirSync(dir, { recursive: true });
36
+ const dbPath = resolve(dir, 'runs.db');
37
+ const webhookStore = new WebhookStore(dbPath);
38
+ const integrationStore = new IntegrationStore(dbPath);
39
+
40
+ // Default to active so existing tests trigger launches as before
41
+ integrationStore.patchConfig('linear', { active: opts.active ?? true });
42
+
43
+ const launched: Array<{ pipeline: string; input: Record<string, unknown>; meta?: Record<string, unknown> }> = [];
44
+ const launcher = {
45
+ launch: vi.fn(async (cfg: { pipeline: string; input: Record<string, unknown>; meta?: Record<string, unknown>; runId: string }) => {
46
+ launched.push({ pipeline: cfg.pipeline, input: cfg.input, meta: cfg.meta });
47
+ return { run_id: cfg.runId };
48
+ }),
49
+ cancel: vi.fn(async () => {}),
50
+ subscribe: vi.fn(() => () => {}),
51
+ };
52
+
53
+ const integrationRuntime = new IntegrationRuntime({
54
+ integrations: [linearIntegration],
55
+ store: integrationStore,
56
+ launcher,
57
+ configsDir: dir,
58
+ apiConfig: { ...(opts.withApiKey ? { key: 'sk-studio-test' } : {}) },
59
+ integrationConfigs: {
60
+ linear: {
61
+ ...(opts.withSecret ? { LINEAR_WEBHOOK_SECRET: WEBHOOK_SECRET } : {}),
62
+ },
63
+ },
64
+ });
65
+
66
+ const server = buildServer({
67
+ store: new InMemoryRunStore(),
68
+ launcher,
69
+ configsDir: dir,
70
+ projectName: 'test-project',
71
+ apiConfig: {
72
+ ...(opts.withApiKey ? { key: 'sk-studio-test' } : {}),
73
+ },
74
+ studioVersion: '0.0.0',
75
+ maskedConfig: { providers: [] },
76
+ webhookStore,
77
+ integrationRuntime,
78
+ integrationStore,
79
+ });
80
+
81
+ return { server, launched, launcher, integrationStore, cleanup: () => { webhookStore.close(); integrationStore.close(); } };
82
+ }
83
+
84
+ function inProgressPayload(overrides: Record<string, unknown> = {}): Record<string, unknown> {
85
+ return {
86
+ type: 'Issue',
87
+ action: 'update',
88
+ data: {
89
+ id: 'abc-123',
90
+ identifier: 'STU-42',
91
+ title: 'Add dark mode toggle',
92
+ description: 'Users want a dark mode option in the settings.',
93
+ state: { name: 'In Progress' },
94
+ ...overrides,
95
+ },
96
+ };
97
+ }
98
+
99
+ describe('POST /api/integrations/linear/webhook — no secret configured', () => {
100
+ let server: ReturnType<typeof makeServer>['server'];
101
+ let launched: ReturnType<typeof makeServer>['launched'];
102
+ let cleanup: () => void;
103
+
104
+ beforeEach(() => {
105
+ ({ server, launched, cleanup } = makeServer());
106
+ });
107
+
108
+ afterEach(() => {
109
+ cleanup();
110
+ });
111
+
112
+ test('accepts "In Progress" transition and launches feature-builder', async () => {
113
+ const payload = inProgressPayload();
114
+ const res = await server.inject({
115
+ method: 'POST',
116
+ url: '/api/integrations/linear/webhook',
117
+ headers: { 'content-type': 'application/json' },
118
+ payload: JSON.stringify(payload),
119
+ });
120
+
121
+ expect(res.statusCode).toBe(202);
122
+ const body = res.json<{ run_id: string; stream_url: string }>();
123
+ expect(body.run_id).toBeDefined();
124
+ expect(body.stream_url).toMatch(/\/api\/runs\/.+\/stream/);
125
+
126
+ expect(launched).toHaveLength(1);
127
+ expect(launched[0].pipeline).toBe('feature-builder');
128
+ expect(launched[0].input['brief_summary']).toBe('STU-42 — Add dark mode toggle');
129
+ expect(launched[0].meta?.['linear_issue_id']).toBe('abc-123');
130
+ expect(launched[0].meta?.['linear_issue_identifier']).toBe('STU-42');
131
+ expect(launched[0].meta?.['linear_issue_url']).toBe('https://linear.app/studioag/issue/STU-42');
132
+ });
133
+
134
+ test('ignores non-"In Progress" state transitions', async () => {
135
+ const payload = inProgressPayload({ state: { name: 'Done' } });
136
+ const res = await server.inject({
137
+ method: 'POST',
138
+ url: '/api/integrations/linear/webhook',
139
+ headers: { 'content-type': 'application/json' },
140
+ payload: JSON.stringify(payload),
141
+ });
142
+
143
+ expect(res.statusCode).toBe(200);
144
+ const body = res.json<{ ignored: boolean; reason: string }>();
145
+ expect(body.ignored).toBe(true);
146
+ expect(body.reason).toContain('Done');
147
+ expect(launched).toHaveLength(0);
148
+ });
149
+
150
+ test('ignores non-update actions', async () => {
151
+ const payload = { ...inProgressPayload(), action: 'create' };
152
+ const res = await server.inject({
153
+ method: 'POST',
154
+ url: '/api/integrations/linear/webhook',
155
+ headers: { 'content-type': 'application/json' },
156
+ payload: JSON.stringify(payload),
157
+ });
158
+
159
+ expect(res.statusCode).toBe(200);
160
+ const body = res.json<{ ignored: boolean }>();
161
+ expect(body.ignored).toBe(true);
162
+ expect(launched).toHaveLength(0);
163
+ });
164
+
165
+ test('ignores non-Issue types', async () => {
166
+ const payload = { ...inProgressPayload(), type: 'Comment' };
167
+ const res = await server.inject({
168
+ method: 'POST',
169
+ url: '/api/integrations/linear/webhook',
170
+ headers: { 'content-type': 'application/json' },
171
+ payload: JSON.stringify(payload),
172
+ });
173
+
174
+ expect(res.statusCode).toBe(200);
175
+ const body = res.json<{ ignored: boolean }>();
176
+ expect(body.ignored).toBe(true);
177
+ expect(launched).toHaveLength(0);
178
+ });
179
+
180
+ test('returns 400 for invalid JSON body', async () => {
181
+ const res = await server.inject({
182
+ method: 'POST',
183
+ url: '/api/integrations/linear/webhook',
184
+ headers: { 'content-type': 'application/json' },
185
+ payload: 'not-json{',
186
+ });
187
+
188
+ expect(res.statusCode).toBe(400);
189
+ });
190
+ });
191
+
192
+ describe('POST /api/integrations/linear/webhook — with HMAC secret', () => {
193
+ let server: ReturnType<typeof makeServer>['server'];
194
+ let launched: ReturnType<typeof makeServer>['launched'];
195
+ let cleanup: () => void;
196
+
197
+ beforeEach(() => {
198
+ ({ server, launched, cleanup } = makeServer({ withSecret: true }));
199
+ });
200
+
201
+ afterEach(() => {
202
+ cleanup();
203
+ });
204
+
205
+ test('accepts request with valid signature', async () => {
206
+ const body = JSON.stringify(inProgressPayload());
207
+ const sig = sign(body, WEBHOOK_SECRET);
208
+
209
+ const res = await server.inject({
210
+ method: 'POST',
211
+ url: '/api/integrations/linear/webhook',
212
+ headers: { 'content-type': 'application/json', 'linear-signature': sig },
213
+ payload: body,
214
+ });
215
+
216
+ expect(res.statusCode).toBe(202);
217
+ expect(launched).toHaveLength(1);
218
+ });
219
+
220
+ test('rejects request with invalid signature', async () => {
221
+ const body = JSON.stringify(inProgressPayload());
222
+
223
+ const res = await server.inject({
224
+ method: 'POST',
225
+ url: '/api/integrations/linear/webhook',
226
+ headers: { 'content-type': 'application/json', 'linear-signature': 'deadbeef' },
227
+ payload: body,
228
+ });
229
+
230
+ expect(res.statusCode).toBe(401);
231
+ expect(launched).toHaveLength(0);
232
+ });
233
+
234
+ test('rejects request with missing signature header', async () => {
235
+ const body = JSON.stringify(inProgressPayload());
236
+
237
+ const res = await server.inject({
238
+ method: 'POST',
239
+ url: '/api/integrations/linear/webhook',
240
+ headers: { 'content-type': 'application/json' },
241
+ payload: body,
242
+ });
243
+
244
+ expect(res.statusCode).toBe(401);
245
+ expect(launched).toHaveLength(0);
246
+ });
247
+
248
+ test('returns 401 not 500 for tampered body', async () => {
249
+ const originalBody = JSON.stringify(inProgressPayload());
250
+ const sig = sign(originalBody, WEBHOOK_SECRET);
251
+ const tamperedBody = originalBody + ' ';
252
+
253
+ const res = await server.inject({
254
+ method: 'POST',
255
+ url: '/api/integrations/linear/webhook',
256
+ headers: { 'content-type': 'application/json', 'linear-signature': sig },
257
+ payload: tamperedBody,
258
+ });
259
+
260
+ expect(res.statusCode).toBe(401);
261
+ expect(launched).toHaveLength(0);
262
+ });
263
+ });
264
+
265
+ describe('POST /api/integrations/linear/webhook — with API key auth enabled', () => {
266
+ let server: ReturnType<typeof makeServer>['server'];
267
+ let launched: ReturnType<typeof makeServer>['launched'];
268
+ let cleanup: () => void;
269
+
270
+ beforeEach(() => {
271
+ ({ server, launched, cleanup } = makeServer({ withApiKey: true }));
272
+ });
273
+
274
+ afterEach(() => {
275
+ cleanup();
276
+ });
277
+
278
+ test('webhook endpoint is exempt from Bearer token auth', async () => {
279
+ // No Authorization header — should not get 401 from the API key check
280
+ const payload = inProgressPayload();
281
+ const res = await server.inject({
282
+ method: 'POST',
283
+ url: '/api/integrations/linear/webhook',
284
+ headers: { 'content-type': 'application/json' },
285
+ payload: JSON.stringify(payload),
286
+ });
287
+
288
+ expect(res.statusCode).toBe(202);
289
+ expect(launched).toHaveLength(1);
290
+ });
291
+
292
+ test('other API routes still require Bearer token', async () => {
293
+ const res = await server.inject({
294
+ method: 'GET',
295
+ url: '/api/runs',
296
+ });
297
+
298
+ expect(res.statusCode).toBe(401);
299
+ });
300
+ });
301
+
302
+ describe('GET /api/integrations/linear', () => {
303
+ let server: ReturnType<typeof makeServer>['server'];
304
+ let integrationStore: ReturnType<typeof makeServer>['integrationStore'];
305
+ let cleanup: () => void;
306
+
307
+ beforeEach(() => {
308
+ ({ server, integrationStore, cleanup } = makeServer({ active: false }));
309
+ });
310
+
311
+ afterEach(() => {
312
+ cleanup();
313
+ });
314
+
315
+ test('returns default config with empty trigger log', async () => {
316
+ const res = await server.inject({ method: 'GET', url: '/api/integrations/linear' });
317
+
318
+ expect(res.statusCode).toBe(200);
319
+ const body = res.json<{ webhook_url: string; pipeline: null; active: boolean; triggers: unknown[] }>();
320
+ expect(body.webhook_url).toMatch(/\/api\/integrations\/linear\/webhook$/);
321
+ expect(body.active).toBe(false);
322
+ expect(body.triggers).toEqual([]);
323
+ });
324
+
325
+ test('reflects config and trigger records after updates', async () => {
326
+ integrationStore.patchConfig('linear', { pipeline: 'feature-builder-cc-linear', active: true });
327
+
328
+ const res = await server.inject({ method: 'GET', url: '/api/integrations/linear' });
329
+ const body = res.json<{ pipeline: string; active: boolean }>();
330
+ expect(body.pipeline).toBe('feature-builder-cc-linear');
331
+ expect(body.active).toBe(true);
332
+ });
333
+ });
334
+
335
+ describe('PATCH /api/integrations/linear', () => {
336
+ let server: ReturnType<typeof makeServer>['server'];
337
+ let cleanup: () => void;
338
+
339
+ beforeEach(() => {
340
+ ({ server, cleanup } = makeServer({ active: false }));
341
+ });
342
+
343
+ afterEach(() => {
344
+ cleanup();
345
+ });
346
+
347
+ test('updates pipeline and active flag', async () => {
348
+ const res = await server.inject({
349
+ method: 'PATCH',
350
+ url: '/api/integrations/linear',
351
+ headers: { 'content-type': 'application/json' },
352
+ payload: JSON.stringify({ pipeline: 'feature-builder-cc-linear', active: true }),
353
+ });
354
+
355
+ expect(res.statusCode).toBe(200);
356
+ const body = res.json<{ pipeline: string; active: boolean }>();
357
+ expect(body.pipeline).toBe('feature-builder-cc-linear');
358
+ expect(body.active).toBe(true);
359
+ });
360
+ });
361
+
362
+ describe('POST /api/integrations/linear/webhook — active flag', () => {
363
+ let server: ReturnType<typeof makeServer>['server'];
364
+ let launched: ReturnType<typeof makeServer>['launched'];
365
+ let cleanup: () => void;
366
+
367
+ beforeEach(() => {
368
+ ({ server, launched, cleanup } = makeServer({ active: false }));
369
+ });
370
+
371
+ afterEach(() => {
372
+ cleanup();
373
+ });
374
+
375
+ test('ignores webhook when integration is inactive', async () => {
376
+ const payload = inProgressPayload();
377
+ const res = await server.inject({
378
+ method: 'POST',
379
+ url: '/api/integrations/linear/webhook',
380
+ headers: { 'content-type': 'application/json' },
381
+ payload: JSON.stringify(payload),
382
+ });
383
+
384
+ expect(res.statusCode).toBe(200);
385
+ const body = res.json<{ ignored: boolean; reason: string }>();
386
+ expect(body.ignored).toBe(true);
387
+ expect(body.reason).toContain('inactive');
388
+ expect(launched).toHaveLength(0);
389
+ });
390
+ });
@@ -0,0 +1,166 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
+ import { mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
+ import { buildServer } from '../src/server.js';
5
+ import { InMemoryRunStore } from '@studio-foundation/engine';
6
+ import type { IntegrationRuntime } from '../src/integration-runtime.js';
7
+ import type { IntegrationStore } from '../src/integration-store.js';
8
+
9
+ const TMP_DIR = resolve('/tmp', `.studio-api-pipelines-test-${Date.now()}`);
10
+ const PIPELINES_DIR = resolve(TMP_DIR, 'pipelines');
11
+
12
+ const FEATURE_BUILDER_YAML = `name: feature-builder
13
+ stages:
14
+ - name: analysis
15
+ agent: analyst
16
+ `;
17
+
18
+ beforeAll(() => {
19
+ mkdirSync(PIPELINES_DIR, { recursive: true });
20
+ writeFileSync(resolve(PIPELINES_DIR, 'feature-builder.pipeline.yaml'), FEATURE_BUILDER_YAML);
21
+ writeFileSync(resolve(PIPELINES_DIR, 'code-review.pipeline.yaml'), 'name: code-review\nstages: []\n');
22
+ writeFileSync(resolve(PIPELINES_DIR, 'not-a-pipeline.yaml'), ''); // should be ignored
23
+ });
24
+
25
+ afterAll(() => {
26
+ rmSync(TMP_DIR, { recursive: true, force: true });
27
+ });
28
+
29
+ const nullIntegrationRuntime = { registerRoutes: () => {} } as unknown as IntegrationRuntime;
30
+ const nullIntegrationStore = {} as unknown as IntegrationStore;
31
+
32
+ function makeServer() {
33
+ return buildServer({
34
+ store: new InMemoryRunStore(),
35
+ launcher: { launch: async () => ({ run_id: 'x' }), cancel: async () => {} },
36
+ configsDir: TMP_DIR,
37
+ projectName: 'test-project',
38
+ apiConfig: {},
39
+ integrationRuntime: nullIntegrationRuntime,
40
+ integrationStore: nullIntegrationStore,
41
+ });
42
+ }
43
+
44
+ describe('GET /api/pipelines', () => {
45
+ it('returns all .pipeline.yaml names', async () => {
46
+ const server = makeServer();
47
+ const res = await server.inject({ method: 'GET', url: '/api/pipelines' });
48
+ expect(res.statusCode).toBe(200);
49
+ const { pipelines } = res.json() as { pipelines: string[] };
50
+ expect(pipelines).toContain('feature-builder');
51
+ expect(pipelines).toContain('code-review');
52
+ expect(pipelines).not.toContain('not-a-pipeline');
53
+ expect(pipelines).not.toContain('feature-builder.pipeline.yaml');
54
+ });
55
+
56
+ it('returns empty list when no pipelines exist', async () => {
57
+ const emptyDir = resolve('/tmp', `.studio-api-empty-${Date.now()}`);
58
+ mkdirSync(emptyDir, { recursive: true });
59
+ const server = buildServer({
60
+ store: new InMemoryRunStore(),
61
+ launcher: { launch: async () => ({ run_id: 'x' }), cancel: async () => {} },
62
+ configsDir: emptyDir,
63
+ projectName: 'empty',
64
+ apiConfig: {},
65
+ integrationRuntime: nullIntegrationRuntime,
66
+ integrationStore: nullIntegrationStore,
67
+ });
68
+ const res = await server.inject({ method: 'GET', url: '/api/pipelines' });
69
+ expect(res.statusCode).toBe(200);
70
+ expect((res.json() as { pipelines: string[] }).pipelines).toEqual([]);
71
+ rmSync(emptyDir, { recursive: true, force: true });
72
+ });
73
+ });
74
+
75
+ describe('GET /api/pipelines/:name', () => {
76
+ it('returns the parsed pipeline YAML as JSON', async () => {
77
+ const server = makeServer();
78
+ const res = await server.inject({ method: 'GET', url: '/api/pipelines/feature-builder' });
79
+ expect(res.statusCode).toBe(200);
80
+ const body = res.json() as { name: string; stages: unknown[] };
81
+ expect(body.name).toBe('feature-builder');
82
+ expect(body.stages).toHaveLength(1);
83
+ });
84
+
85
+ it('returns 404 for unknown pipeline', async () => {
86
+ const server = makeServer();
87
+ const res = await server.inject({ method: 'GET', url: '/api/pipelines/nonexistent' });
88
+ expect(res.statusCode).toBe(404);
89
+ expect((res.json() as { error: string }).error).toBeTruthy();
90
+ });
91
+ });
92
+
93
+ describe('PUT /api/pipelines/:name', () => {
94
+ it('creates a new pipeline from YAML body', async () => {
95
+ const server = makeServer();
96
+ const yaml = 'name: new-pipeline\nstages: []\n';
97
+ const res = await server.inject({
98
+ method: 'PUT',
99
+ url: '/api/pipelines/new-pipeline',
100
+ headers: { 'content-type': 'text/plain' },
101
+ payload: yaml,
102
+ });
103
+ expect(res.statusCode).toBe(200);
104
+ expect(existsSync(resolve(PIPELINES_DIR, 'new-pipeline.pipeline.yaml'))).toBe(true);
105
+ });
106
+
107
+ it('updates an existing pipeline from YAML body', async () => {
108
+ const server = makeServer();
109
+ const yaml = 'name: feature-builder\nstages: []\n';
110
+ const res = await server.inject({
111
+ method: 'PUT',
112
+ url: '/api/pipelines/feature-builder',
113
+ headers: { 'content-type': 'text/plain' },
114
+ payload: yaml,
115
+ });
116
+ expect(res.statusCode).toBe(200);
117
+
118
+ // Verify content updated
119
+ const get = await server.inject({ method: 'GET', url: '/api/pipelines/feature-builder' });
120
+ const body = get.json() as { stages: unknown[] };
121
+ expect(body.stages).toHaveLength(0);
122
+ });
123
+
124
+ it('creates a pipeline from JSON body', async () => {
125
+ const server = makeServer();
126
+ const res = await server.inject({
127
+ method: 'PUT',
128
+ url: '/api/pipelines/json-pipeline',
129
+ headers: { 'content-type': 'application/json' },
130
+ payload: { name: 'json-pipeline', stages: [] },
131
+ });
132
+ expect(res.statusCode).toBe(200);
133
+ expect(existsSync(resolve(PIPELINES_DIR, 'json-pipeline.pipeline.yaml'))).toBe(true);
134
+ });
135
+
136
+ it('returns 400 for invalid YAML', async () => {
137
+ const server = makeServer();
138
+ const res = await server.inject({
139
+ method: 'PUT',
140
+ url: '/api/pipelines/bad-pipeline',
141
+ headers: { 'content-type': 'text/plain' },
142
+ payload: 'key: [unclosed\n - item',
143
+ });
144
+ expect(res.statusCode).toBe(400);
145
+ expect((res.json() as { error: string }).error).toBeTruthy();
146
+ });
147
+ });
148
+
149
+ describe('DELETE /api/pipelines/:name', () => {
150
+ it('deletes an existing pipeline', async () => {
151
+ const server = makeServer();
152
+ // Create a file to delete
153
+ writeFileSync(resolve(PIPELINES_DIR, 'to-delete.pipeline.yaml'), 'name: to-delete\n');
154
+
155
+ const res = await server.inject({ method: 'DELETE', url: '/api/pipelines/to-delete' });
156
+ expect(res.statusCode).toBe(200);
157
+ expect(existsSync(resolve(PIPELINES_DIR, 'to-delete.pipeline.yaml'))).toBe(false);
158
+ });
159
+
160
+ it('returns 404 for non-existent pipeline', async () => {
161
+ const server = makeServer();
162
+ const res = await server.inject({ method: 'DELETE', url: '/api/pipelines/ghost-pipeline' });
163
+ expect(res.statusCode).toBe(404);
164
+ expect((res.json() as { error: string }).error).toBeTruthy();
165
+ });
166
+ });