@vibescope/mcp-server 0.2.1 → 0.2.3

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 (93) hide show
  1. package/README.md +63 -38
  2. package/dist/api-client.d.ts +187 -0
  3. package/dist/api-client.js +53 -1
  4. package/dist/handlers/blockers.js +9 -8
  5. package/dist/handlers/bodies-of-work.js +14 -14
  6. package/dist/handlers/connectors.d.ts +45 -0
  7. package/dist/handlers/connectors.js +183 -0
  8. package/dist/handlers/cost.d.ts +10 -0
  9. package/dist/handlers/cost.js +54 -0
  10. package/dist/handlers/decisions.js +3 -3
  11. package/dist/handlers/deployment.js +35 -19
  12. package/dist/handlers/discovery.d.ts +7 -0
  13. package/dist/handlers/discovery.js +61 -2
  14. package/dist/handlers/fallback.js +5 -4
  15. package/dist/handlers/file-checkouts.d.ts +2 -0
  16. package/dist/handlers/file-checkouts.js +38 -6
  17. package/dist/handlers/findings.js +13 -12
  18. package/dist/handlers/git-issues.js +4 -4
  19. package/dist/handlers/ideas.js +5 -5
  20. package/dist/handlers/index.d.ts +1 -0
  21. package/dist/handlers/index.js +3 -0
  22. package/dist/handlers/milestones.js +5 -5
  23. package/dist/handlers/organizations.js +13 -13
  24. package/dist/handlers/progress.js +2 -2
  25. package/dist/handlers/project.js +6 -6
  26. package/dist/handlers/requests.js +3 -3
  27. package/dist/handlers/session.js +28 -9
  28. package/dist/handlers/sprints.js +17 -17
  29. package/dist/handlers/tasks.d.ts +2 -0
  30. package/dist/handlers/tasks.js +78 -20
  31. package/dist/handlers/types.d.ts +64 -2
  32. package/dist/handlers/types.js +48 -1
  33. package/dist/handlers/validation.js +3 -3
  34. package/dist/index.js +7 -2716
  35. package/dist/token-tracking.d.ts +74 -0
  36. package/dist/token-tracking.js +122 -0
  37. package/dist/tools.js +298 -9
  38. package/dist/utils.d.ts +5 -0
  39. package/dist/utils.js +17 -0
  40. package/docs/TOOLS.md +2053 -0
  41. package/package.json +4 -1
  42. package/scripts/generate-docs.ts +212 -0
  43. package/src/api-client.test.ts +723 -0
  44. package/src/api-client.ts +236 -1
  45. package/src/handlers/__test-setup__.ts +9 -0
  46. package/src/handlers/blockers.test.ts +31 -19
  47. package/src/handlers/blockers.ts +9 -8
  48. package/src/handlers/bodies-of-work.test.ts +55 -32
  49. package/src/handlers/bodies-of-work.ts +14 -14
  50. package/src/handlers/connectors.test.ts +834 -0
  51. package/src/handlers/connectors.ts +229 -0
  52. package/src/handlers/cost.ts +66 -0
  53. package/src/handlers/decisions.test.ts +34 -25
  54. package/src/handlers/decisions.ts +3 -3
  55. package/src/handlers/deployment.ts +39 -19
  56. package/src/handlers/discovery.ts +61 -2
  57. package/src/handlers/fallback.test.ts +26 -22
  58. package/src/handlers/fallback.ts +5 -4
  59. package/src/handlers/file-checkouts.test.ts +242 -49
  60. package/src/handlers/file-checkouts.ts +44 -6
  61. package/src/handlers/findings.test.ts +38 -24
  62. package/src/handlers/findings.ts +13 -12
  63. package/src/handlers/git-issues.test.ts +51 -43
  64. package/src/handlers/git-issues.ts +4 -4
  65. package/src/handlers/ideas.test.ts +28 -23
  66. package/src/handlers/ideas.ts +5 -5
  67. package/src/handlers/index.ts +3 -0
  68. package/src/handlers/milestones.test.ts +33 -28
  69. package/src/handlers/milestones.ts +5 -5
  70. package/src/handlers/organizations.test.ts +104 -83
  71. package/src/handlers/organizations.ts +13 -13
  72. package/src/handlers/progress.test.ts +20 -14
  73. package/src/handlers/progress.ts +2 -2
  74. package/src/handlers/project.test.ts +34 -27
  75. package/src/handlers/project.ts +6 -6
  76. package/src/handlers/requests.test.ts +27 -18
  77. package/src/handlers/requests.ts +3 -3
  78. package/src/handlers/session.test.ts +47 -0
  79. package/src/handlers/session.ts +26 -9
  80. package/src/handlers/sprints.test.ts +71 -50
  81. package/src/handlers/sprints.ts +17 -17
  82. package/src/handlers/tasks.test.ts +77 -15
  83. package/src/handlers/tasks.ts +90 -21
  84. package/src/handlers/tool-categories.test.ts +66 -0
  85. package/src/handlers/types.ts +81 -2
  86. package/src/handlers/validation.test.ts +78 -45
  87. package/src/handlers/validation.ts +3 -3
  88. package/src/index.ts +12 -2732
  89. package/src/token-tracking.test.ts +453 -0
  90. package/src/token-tracking.ts +164 -0
  91. package/src/tools.ts +298 -9
  92. package/src/utils.test.ts +2 -2
  93. package/src/utils.ts +17 -0
@@ -38,8 +38,8 @@ async function getToolDocs(): Promise<Record<string, string>> {
38
38
  return toolInfoCache;
39
39
  }
40
40
 
41
- // Tool categories with brief descriptions
42
- const TOOL_CATEGORIES: Record<string, { description: string; tools: Array<{ name: string; brief: string }> }> = {
41
+ // Tool categories with brief descriptions (exported for documentation generation)
42
+ export const TOOL_CATEGORIES: Record<string, { description: string; tools: Array<{ name: string; brief: string }> }> = {
43
43
  session: {
44
44
  description: 'Session lifecycle and monitoring',
45
45
  tools: [
@@ -124,6 +124,7 @@ const TOOL_CATEGORIES: Record<string, { description: string; tools: Array<{ name
124
124
  tools: [
125
125
  { name: 'add_finding', brief: 'Record audit finding' },
126
126
  { name: 'get_findings', brief: 'List findings' },
127
+ { name: 'get_findings_stats', brief: 'Get findings statistics' },
127
128
  { name: 'update_finding', brief: 'Update finding status' },
128
129
  { name: 'delete_finding', brief: 'Remove finding' },
129
130
  ],
@@ -177,6 +178,10 @@ const TOOL_CATEGORIES: Record<string, { description: string; tools: Array<{ name
177
178
  { name: 'add_task_to_body_of_work', brief: 'Add task to group' },
178
179
  { name: 'remove_task_from_body_of_work', brief: 'Remove from group' },
179
180
  { name: 'activate_body_of_work', brief: 'Activate for work' },
181
+ { name: 'add_task_dependency', brief: 'Add task dependency' },
182
+ { name: 'remove_task_dependency', brief: 'Remove task dependency' },
183
+ { name: 'get_task_dependencies', brief: 'List task dependencies' },
184
+ { name: 'get_next_body_of_work_task', brief: 'Get next available task' },
180
185
  ],
181
186
  },
182
187
  sprints: {
@@ -230,6 +235,8 @@ const TOOL_CATEGORIES: Record<string, { description: string; tools: Array<{ name
230
235
  { name: 'update_cost_alert', brief: 'Update alert config' },
231
236
  { name: 'delete_cost_alert', brief: 'Remove alert' },
232
237
  { name: 'get_task_costs', brief: 'Cost per task' },
238
+ { name: 'get_body_of_work_costs', brief: 'Cost per body of work' },
239
+ { name: 'get_sprint_costs', brief: 'Cost per sprint' },
233
240
  ],
234
241
  },
235
242
  git_issues: {
@@ -247,6 +254,58 @@ const TOOL_CATEGORIES: Record<string, { description: string; tools: Array<{ name
247
254
  { name: 'query_knowledge_base', brief: 'Aggregated project knowledge in one call' },
248
255
  ],
249
256
  },
257
+ discovery: {
258
+ description: 'Tool discovery and documentation',
259
+ tools: [
260
+ { name: 'discover_tools', brief: 'List tools by category' },
261
+ { name: 'get_tool_info', brief: 'Get detailed tool docs' },
262
+ ],
263
+ },
264
+ subtasks: {
265
+ description: 'Break tasks into smaller pieces',
266
+ tools: [
267
+ { name: 'add_subtask', brief: 'Add subtask to task' },
268
+ { name: 'get_subtasks', brief: 'List task subtasks' },
269
+ ],
270
+ },
271
+ worktrees: {
272
+ description: 'Git worktree management',
273
+ tools: [
274
+ { name: 'get_stale_worktrees', brief: 'Find orphaned worktrees' },
275
+ { name: 'clear_worktree_path', brief: 'Clear worktree from task' },
276
+ ],
277
+ },
278
+ roles: {
279
+ description: 'Agent role management',
280
+ tools: [
281
+ { name: 'get_role_settings', brief: 'Get project role settings' },
282
+ { name: 'update_role_settings', brief: 'Configure role behavior' },
283
+ { name: 'set_session_role', brief: 'Set session role' },
284
+ { name: 'get_agents_by_role', brief: 'List agents by role' },
285
+ ],
286
+ },
287
+ file_locks: {
288
+ description: 'File checkout/locking for multi-agent',
289
+ tools: [
290
+ { name: 'checkout_file', brief: 'Lock file for editing' },
291
+ { name: 'checkin_file', brief: 'Release file lock' },
292
+ { name: 'get_file_checkouts', brief: 'List file locks' },
293
+ { name: 'abandon_checkout', brief: 'Force-release lock' },
294
+ { name: 'is_file_available', brief: 'Check if file is free' },
295
+ ],
296
+ },
297
+ connectors: {
298
+ description: 'External integration connectors',
299
+ tools: [
300
+ { name: 'get_connectors', brief: 'List project connectors' },
301
+ { name: 'get_connector', brief: 'Get connector details' },
302
+ { name: 'add_connector', brief: 'Create new connector' },
303
+ { name: 'update_connector', brief: 'Update connector config' },
304
+ { name: 'delete_connector', brief: 'Remove connector' },
305
+ { name: 'test_connector', brief: 'Send test event' },
306
+ { name: 'get_connector_events', brief: 'Event history' },
307
+ ],
308
+ },
250
309
  };
251
310
 
252
311
  export const discoverTools: Handler = async (args) => {
@@ -48,7 +48,7 @@ describe('startFallbackActivity', () => {
48
48
  project_id: '123e4567-e89b-12d3-a456-426614174000',
49
49
  activity: 'invalid_activity',
50
50
  }, ctx)
51
- ).rejects.toThrow('Invalid activity');
51
+ ).rejects.toThrow(ValidationError);
52
52
  });
53
53
 
54
54
  it('should start fallback activity successfully', async () => {
@@ -131,19 +131,20 @@ describe('startFallbackActivity', () => {
131
131
  }
132
132
  });
133
133
 
134
- it('should throw error when API call fails', async () => {
134
+ it('should return error when API call fails', async () => {
135
135
  mockApiClient.startFallbackActivity.mockResolvedValue({
136
136
  ok: false,
137
137
  error: 'Failed to start activity',
138
138
  });
139
139
  const ctx = createMockContext();
140
140
 
141
- await expect(
142
- startFallbackActivity({
143
- project_id: '123e4567-e89b-12d3-a456-426614174000',
144
- activity: 'code_review',
145
- }, ctx)
146
- ).rejects.toThrow('Failed to start fallback activity');
141
+ const result = await startFallbackActivity({
142
+ project_id: '123e4567-e89b-12d3-a456-426614174000',
143
+ activity: 'code_review',
144
+ }, ctx);
145
+
146
+ expect(result.isError).toBe(true);
147
+ expect(result.result).toMatchObject({ error: 'Failed to start activity' });
147
148
  });
148
149
 
149
150
  it('should pass through worktree guidance when API returns it', async () => {
@@ -286,18 +287,19 @@ describe('stopFallbackActivity', () => {
286
287
  );
287
288
  });
288
289
 
289
- it('should throw error when API call fails', async () => {
290
+ it('should return error when API call fails', async () => {
290
291
  mockApiClient.stopFallbackActivity.mockResolvedValue({
291
292
  ok: false,
292
293
  error: 'Failed to stop activity',
293
294
  });
294
295
  const ctx = createMockContext();
295
296
 
296
- await expect(
297
- stopFallbackActivity({
298
- project_id: '123e4567-e89b-12d3-a456-426614174000',
299
- }, ctx)
300
- ).rejects.toThrow('Failed to stop fallback activity');
297
+ const result = await stopFallbackActivity({
298
+ project_id: '123e4567-e89b-12d3-a456-426614174000',
299
+ }, ctx);
300
+
301
+ expect(result.isError).toBe(true);
302
+ expect(result.result).toMatchObject({ error: 'Failed to stop activity' });
301
303
  });
302
304
  });
303
305
 
@@ -405,16 +407,17 @@ describe('getActivityHistory', () => {
405
407
  );
406
408
  });
407
409
 
408
- it('should throw error when API call fails', async () => {
410
+ it('should return error when API call fails', async () => {
409
411
  mockApiClient.proxy.mockResolvedValue({
410
412
  ok: false,
411
413
  error: 'Query failed',
412
414
  });
413
415
  const ctx = createMockContext();
414
416
 
415
- await expect(
416
- getActivityHistory({ project_id: '123e4567-e89b-12d3-a456-426614174000' }, ctx)
417
- ).rejects.toThrow('Failed to get activity history');
417
+ const result = await getActivityHistory({ project_id: '123e4567-e89b-12d3-a456-426614174000' }, ctx);
418
+
419
+ expect(result.isError).toBe(true);
420
+ expect(result.result).toMatchObject({ error: 'Query failed' });
418
421
  });
419
422
  });
420
423
 
@@ -518,15 +521,16 @@ describe('getActivitySchedules', () => {
518
521
  );
519
522
  });
520
523
 
521
- it('should throw error when API call fails', async () => {
524
+ it('should return error when API call fails', async () => {
522
525
  mockApiClient.proxy.mockResolvedValue({
523
526
  ok: false,
524
527
  error: 'Query failed',
525
528
  });
526
529
  const ctx = createMockContext();
527
530
 
528
- await expect(
529
- getActivitySchedules({ project_id: '123e4567-e89b-12d3-a456-426614174000' }, ctx)
530
- ).rejects.toThrow('Failed to get activity schedules');
531
+ const result = await getActivitySchedules({ project_id: '123e4567-e89b-12d3-a456-426614174000' }, ctx);
532
+
533
+ expect(result.isError).toBe(true);
534
+ expect(result.result).toMatchObject({ error: 'Query failed' });
531
535
  });
532
536
  });
@@ -26,6 +26,7 @@ const VALID_ACTIVITIES = [
26
26
  'documentation_review',
27
27
  'dependency_audit',
28
28
  'validate_completed_tasks',
29
+ 'worktree_cleanup',
29
30
  ] as const;
30
31
 
31
32
  type FallbackActivity = typeof VALID_ACTIVITIES[number];
@@ -60,7 +61,7 @@ export const startFallbackActivity: Handler = async (args, ctx) => {
60
61
  const response = await apiClient.startFallbackActivity(project_id, activity as FallbackActivity, session.currentSessionId || undefined);
61
62
 
62
63
  if (!response.ok) {
63
- throw new Error(`Failed to start fallback activity: ${response.error}`);
64
+ return { result: { error: response.error || 'Failed to start fallback activity' }, isError: true };
64
65
  }
65
66
 
66
67
  // Get the activity details for the response
@@ -98,7 +99,7 @@ export const stopFallbackActivity: Handler = async (args, ctx) => {
98
99
  const response = await apiClient.stopFallbackActivity(project_id, summary, session.currentSessionId || undefined);
99
100
 
100
101
  if (!response.ok) {
101
- throw new Error(`Failed to stop fallback activity: ${response.error}`);
102
+ return { result: { error: response.error || 'Failed to stop fallback activity' }, isError: true };
102
103
  }
103
104
 
104
105
  return {
@@ -131,7 +132,7 @@ export const getActivityHistory: Handler = async (args, _ctx) => {
131
132
  });
132
133
 
133
134
  if (!response.ok) {
134
- throw new Error(`Failed to get activity history: ${response.error}`);
135
+ return { result: { error: response.error || 'Failed to get activity history' }, isError: true };
135
136
  }
136
137
 
137
138
  return {
@@ -164,7 +165,7 @@ export const getActivitySchedules: Handler = async (args, _ctx) => {
164
165
  });
165
166
 
166
167
  if (!response.ok) {
167
- throw new Error(`Failed to get activity schedules: ${response.error}`);
168
+ return { result: { error: response.error || 'Failed to get activity schedules' }, isError: true };
168
169
  }
169
170
 
170
171
  return {
@@ -4,6 +4,7 @@ import {
4
4
  checkinFile,
5
5
  getFileCheckouts,
6
6
  abandonCheckout,
7
+ isFileAvailable,
7
8
  } from './file-checkouts.js';
8
9
  import { ValidationError } from '../validators.js';
9
10
  import { createMockContext, testUUID } from './__test-utils__.js';
@@ -87,33 +88,35 @@ describe('checkoutFile', () => {
87
88
  );
88
89
  });
89
90
 
90
- it('should throw error when API call fails', async () => {
91
+ it('should return error when API call fails', async () => {
91
92
  mockApiClient.checkoutFile.mockResolvedValue({
92
93
  ok: false,
93
94
  error: 'File already checked out',
94
95
  });
95
96
  const ctx = createMockContext();
96
97
 
97
- await expect(
98
- checkoutFile({
99
- project_id: VALID_UUID,
100
- file_path: '/src/index.ts',
101
- }, ctx)
102
- ).rejects.toThrow('File already checked out');
98
+ const result = await checkoutFile({
99
+ project_id: VALID_UUID,
100
+ file_path: '/src/index.ts',
101
+ }, ctx);
102
+
103
+ expect(result.isError).toBe(true);
104
+ expect(result.result).toMatchObject({ error: 'File already checked out' });
103
105
  });
104
106
 
105
- it('should throw default error when API fails without message', async () => {
107
+ it('should return default error when API fails without message', async () => {
106
108
  mockApiClient.checkoutFile.mockResolvedValue({
107
109
  ok: false,
108
110
  });
109
111
  const ctx = createMockContext();
110
112
 
111
- await expect(
112
- checkoutFile({
113
- project_id: VALID_UUID,
114
- file_path: '/src/index.ts',
115
- }, ctx)
116
- ).rejects.toThrow('Failed to checkout file');
113
+ const result = await checkoutFile({
114
+ project_id: VALID_UUID,
115
+ file_path: '/src/index.ts',
116
+ }, ctx);
117
+
118
+ expect(result.isError).toBe(true);
119
+ expect(result.result).toMatchObject({ error: 'Failed to checkout file' });
117
120
  });
118
121
  });
119
122
 
@@ -124,28 +127,31 @@ describe('checkoutFile', () => {
124
127
  describe('checkinFile', () => {
125
128
  beforeEach(() => vi.clearAllMocks());
126
129
 
127
- it('should throw error when neither checkout_id nor project_id+file_path provided', async () => {
130
+ it('should return error when neither checkout_id nor project_id+file_path provided', async () => {
128
131
  const ctx = createMockContext();
129
132
 
130
- await expect(
131
- checkinFile({}, ctx)
132
- ).rejects.toThrow('Either checkout_id or both project_id and file_path are required');
133
+ const result = await checkinFile({}, ctx);
134
+
135
+ expect(result.isError).toBe(true);
136
+ expect(result.result).toMatchObject({ error: 'Either checkout_id or both project_id and file_path are required' });
133
137
  });
134
138
 
135
- it('should throw error when only project_id provided without file_path', async () => {
139
+ it('should return error when only project_id provided without file_path', async () => {
136
140
  const ctx = createMockContext();
137
141
 
138
- await expect(
139
- checkinFile({ project_id: VALID_UUID }, ctx)
140
- ).rejects.toThrow('Either checkout_id or both project_id and file_path are required');
142
+ const result = await checkinFile({ project_id: VALID_UUID }, ctx);
143
+
144
+ expect(result.isError).toBe(true);
145
+ expect(result.result).toMatchObject({ error: 'Either checkout_id or both project_id and file_path are required' });
141
146
  });
142
147
 
143
- it('should throw error when only file_path provided without project_id', async () => {
148
+ it('should return error when only file_path provided without project_id', async () => {
144
149
  const ctx = createMockContext();
145
150
 
146
- await expect(
147
- checkinFile({ file_path: '/src/index.ts' }, ctx)
148
- ).rejects.toThrow('Either checkout_id or both project_id and file_path are required');
151
+ const result = await checkinFile({ file_path: '/src/index.ts' }, ctx);
152
+
153
+ expect(result.isError).toBe(true);
154
+ expect(result.result).toMatchObject({ error: 'Either checkout_id or both project_id and file_path are required' });
149
155
  });
150
156
 
151
157
  it('should throw error for invalid checkout_id UUID', async () => {
@@ -215,16 +221,17 @@ describe('checkinFile', () => {
215
221
  );
216
222
  });
217
223
 
218
- it('should throw error when API call fails', async () => {
224
+ it('should return error when API call fails', async () => {
219
225
  mockApiClient.checkinFile.mockResolvedValue({
220
226
  ok: false,
221
227
  error: 'Checkout not found',
222
228
  });
223
229
  const ctx = createMockContext();
224
230
 
225
- await expect(
226
- checkinFile({ checkout_id: VALID_UUID }, ctx)
227
- ).rejects.toThrow('Checkout not found');
231
+ const result = await checkinFile({ checkout_id: VALID_UUID }, ctx);
232
+
233
+ expect(result.isError).toBe(true);
234
+ expect(result.result).toMatchObject({ error: 'Checkout not found' });
228
235
  });
229
236
  });
230
237
 
@@ -353,16 +360,17 @@ describe('getFileCheckouts', () => {
353
360
  expect(mockApiClient.getFileCheckouts).toHaveBeenCalledTimes(3);
354
361
  });
355
362
 
356
- it('should throw error when API call fails', async () => {
363
+ it('should return error when API call fails', async () => {
357
364
  mockApiClient.getFileCheckouts.mockResolvedValue({
358
365
  ok: false,
359
366
  error: 'Database error',
360
367
  });
361
368
  const ctx = createMockContext();
362
369
 
363
- await expect(
364
- getFileCheckouts({ project_id: VALID_UUID }, ctx)
365
- ).rejects.toThrow('Database error');
370
+ const result = await getFileCheckouts({ project_id: VALID_UUID }, ctx);
371
+
372
+ expect(result.isError).toBe(true);
373
+ expect(result.result).toMatchObject({ error: 'Database error' });
366
374
  });
367
375
  });
368
376
 
@@ -373,20 +381,22 @@ describe('getFileCheckouts', () => {
373
381
  describe('abandonCheckout', () => {
374
382
  beforeEach(() => vi.clearAllMocks());
375
383
 
376
- it('should throw error when neither checkout_id nor project_id+file_path provided', async () => {
384
+ it('should return error when neither checkout_id nor project_id+file_path provided', async () => {
377
385
  const ctx = createMockContext();
378
386
 
379
- await expect(
380
- abandonCheckout({}, ctx)
381
- ).rejects.toThrow('Either checkout_id or both project_id and file_path are required');
387
+ const result = await abandonCheckout({}, ctx);
388
+
389
+ expect(result.isError).toBe(true);
390
+ expect(result.result).toMatchObject({ error: 'Either checkout_id or both project_id and file_path are required' });
382
391
  });
383
392
 
384
- it('should throw error when only project_id provided without file_path', async () => {
393
+ it('should return error when only project_id provided without file_path', async () => {
385
394
  const ctx = createMockContext();
386
395
 
387
- await expect(
388
- abandonCheckout({ project_id: VALID_UUID }, ctx)
389
- ).rejects.toThrow('Either checkout_id or both project_id and file_path are required');
396
+ const result = await abandonCheckout({ project_id: VALID_UUID }, ctx);
397
+
398
+ expect(result.isError).toBe(true);
399
+ expect(result.result).toMatchObject({ error: 'Either checkout_id or both project_id and file_path are required' });
390
400
  });
391
401
 
392
402
  it('should throw error for invalid checkout_id UUID', async () => {
@@ -452,26 +462,209 @@ describe('abandonCheckout', () => {
452
462
  });
453
463
  });
454
464
 
455
- it('should throw error when API call fails', async () => {
465
+ it('should return error when API call fails', async () => {
456
466
  mockApiClient.abandonCheckout.mockResolvedValue({
457
467
  ok: false,
458
468
  error: 'Checkout not found',
459
469
  });
460
470
  const ctx = createMockContext();
461
471
 
462
- await expect(
463
- abandonCheckout({ checkout_id: VALID_UUID }, ctx)
464
- ).rejects.toThrow('Checkout not found');
472
+ const result = await abandonCheckout({ checkout_id: VALID_UUID }, ctx);
473
+
474
+ expect(result.isError).toBe(true);
475
+ expect(result.result).toMatchObject({ error: 'Checkout not found' });
465
476
  });
466
477
 
467
- it('should throw default error when API fails without message', async () => {
478
+ it('should return default error when API fails without message', async () => {
468
479
  mockApiClient.abandonCheckout.mockResolvedValue({
469
480
  ok: false,
470
481
  });
471
482
  const ctx = createMockContext();
472
483
 
484
+ const result = await abandonCheckout({ checkout_id: VALID_UUID }, ctx);
485
+
486
+ expect(result.isError).toBe(true);
487
+ expect(result.result).toMatchObject({ error: 'Failed to abandon checkout' });
488
+ });
489
+ });
490
+
491
+ // ============================================================================
492
+ // isFileAvailable Tests
493
+ // ============================================================================
494
+
495
+ describe('isFileAvailable', () => {
496
+ beforeEach(() => vi.clearAllMocks());
497
+
498
+ it('should throw error for missing project_id', async () => {
499
+ const ctx = createMockContext();
500
+
473
501
  await expect(
474
- abandonCheckout({ checkout_id: VALID_UUID }, ctx)
475
- ).rejects.toThrow('Failed to abandon checkout');
502
+ isFileAvailable({ file_path: '/src/index.ts' }, ctx)
503
+ ).rejects.toThrow(ValidationError);
504
+ });
505
+
506
+ it('should throw error for invalid project_id UUID', async () => {
507
+ const ctx = createMockContext();
508
+
509
+ await expect(
510
+ isFileAvailable({ project_id: 'invalid', file_path: '/src/index.ts' }, ctx)
511
+ ).rejects.toThrow(ValidationError);
512
+ });
513
+
514
+ it('should throw error for missing file_path', async () => {
515
+ const ctx = createMockContext();
516
+
517
+ await expect(
518
+ isFileAvailable({ project_id: VALID_UUID }, ctx)
519
+ ).rejects.toThrow(ValidationError);
520
+ });
521
+
522
+ it('should return available=true when file has no active checkout', async () => {
523
+ mockApiClient.getFileCheckouts.mockResolvedValue({
524
+ ok: true,
525
+ data: { checkouts: [] },
526
+ });
527
+ const ctx = createMockContext();
528
+
529
+ const result = await isFileAvailable(
530
+ {
531
+ project_id: VALID_UUID,
532
+ file_path: '/src/index.ts',
533
+ },
534
+ ctx
535
+ );
536
+
537
+ expect(result.result).toMatchObject({
538
+ available: true,
539
+ file_path: '/src/index.ts',
540
+ checked_out_by: null,
541
+ });
542
+ });
543
+
544
+ it('should return available=false with checkout info when file is checked out', async () => {
545
+ mockApiClient.getFileCheckouts.mockResolvedValue({
546
+ ok: true,
547
+ data: {
548
+ checkouts: [{
549
+ id: 'checkout-123',
550
+ file_path: '/src/index.ts',
551
+ status: 'checked_out',
552
+ checked_out_by: 'Apex',
553
+ checked_out_at: '2026-01-16T10:00:00Z',
554
+ checkout_reason: 'Working on feature X',
555
+ }],
556
+ },
557
+ });
558
+ const ctx = createMockContext();
559
+
560
+ const result = await isFileAvailable(
561
+ {
562
+ project_id: VALID_UUID,
563
+ file_path: '/src/index.ts',
564
+ },
565
+ ctx
566
+ );
567
+
568
+ expect(result.result).toMatchObject({
569
+ available: false,
570
+ file_path: '/src/index.ts',
571
+ checked_out_by: {
572
+ checkout_id: 'checkout-123',
573
+ checked_out_by: 'Apex',
574
+ checked_out_at: '2026-01-16T10:00:00Z',
575
+ reason: 'Working on feature X',
576
+ },
577
+ });
578
+ });
579
+
580
+ it('should query API with correct parameters', async () => {
581
+ mockApiClient.getFileCheckouts.mockResolvedValue({
582
+ ok: true,
583
+ data: { checkouts: [] },
584
+ });
585
+ const ctx = createMockContext();
586
+
587
+ await isFileAvailable(
588
+ {
589
+ project_id: VALID_UUID,
590
+ file_path: '/src/index.ts',
591
+ },
592
+ ctx
593
+ );
594
+
595
+ expect(mockApiClient.getFileCheckouts).toHaveBeenCalledWith(VALID_UUID, {
596
+ status: 'checked_out',
597
+ file_path: '/src/index.ts',
598
+ limit: 1,
599
+ });
600
+ });
601
+
602
+ it('should return error when API call fails', async () => {
603
+ mockApiClient.getFileCheckouts.mockResolvedValue({
604
+ ok: false,
605
+ error: 'Project not found',
606
+ });
607
+ const ctx = createMockContext();
608
+
609
+ const result = await isFileAvailable({
610
+ project_id: VALID_UUID,
611
+ file_path: '/src/index.ts',
612
+ }, ctx);
613
+
614
+ expect(result.isError).toBe(true);
615
+ expect(result.result).toMatchObject({ error: 'Project not found' });
616
+ });
617
+
618
+ it('should return default error when API fails without message', async () => {
619
+ mockApiClient.getFileCheckouts.mockResolvedValue({
620
+ ok: false,
621
+ });
622
+ const ctx = createMockContext();
623
+
624
+ const result = await isFileAvailable({
625
+ project_id: VALID_UUID,
626
+ file_path: '/src/index.ts',
627
+ }, ctx);
628
+
629
+ expect(result.isError).toBe(true);
630
+ expect(result.result).toMatchObject({ error: 'Failed to check file availability' });
631
+ });
632
+
633
+ it('should handle empty checkouts array gracefully', async () => {
634
+ mockApiClient.getFileCheckouts.mockResolvedValue({
635
+ ok: true,
636
+ data: { checkouts: [] },
637
+ });
638
+ const ctx = createMockContext();
639
+
640
+ const result = await isFileAvailable(
641
+ {
642
+ project_id: VALID_UUID,
643
+ file_path: '/src/index.ts',
644
+ },
645
+ ctx
646
+ );
647
+
648
+ expect(result.result.available).toBe(true);
649
+ expect(result.result.checked_out_by).toBeNull();
650
+ });
651
+
652
+ it('should handle undefined checkouts gracefully', async () => {
653
+ mockApiClient.getFileCheckouts.mockResolvedValue({
654
+ ok: true,
655
+ data: {},
656
+ });
657
+ const ctx = createMockContext();
658
+
659
+ const result = await isFileAvailable(
660
+ {
661
+ project_id: VALID_UUID,
662
+ file_path: '/src/index.ts',
663
+ },
664
+ ctx
665
+ );
666
+
667
+ expect(result.result.available).toBe(true);
668
+ expect(result.result.checked_out_by).toBeNull();
476
669
  });
477
670
  });