loopwork 0.3.0 → 0.3.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 (46) hide show
  1. package/bin/loopwork +0 -0
  2. package/package.json +48 -4
  3. package/src/backends/github.ts +6 -3
  4. package/src/backends/json.ts +28 -10
  5. package/src/commands/run.ts +2 -2
  6. package/src/contracts/config.ts +3 -75
  7. package/src/contracts/index.ts +0 -6
  8. package/src/core/cli.ts +25 -16
  9. package/src/core/state.ts +10 -4
  10. package/src/core/utils.ts +10 -4
  11. package/src/monitor/index.ts +56 -34
  12. package/src/plugins/index.ts +9 -131
  13. package/examples/README.md +0 -70
  14. package/examples/basic-json-backend/.specs/tasks/TASK-001.md +0 -22
  15. package/examples/basic-json-backend/.specs/tasks/TASK-002.md +0 -23
  16. package/examples/basic-json-backend/.specs/tasks/TASK-003.md +0 -37
  17. package/examples/basic-json-backend/.specs/tasks/tasks.json +0 -19
  18. package/examples/basic-json-backend/README.md +0 -32
  19. package/examples/basic-json-backend/TESTING.md +0 -184
  20. package/examples/basic-json-backend/hello.test.ts +0 -9
  21. package/examples/basic-json-backend/hello.ts +0 -3
  22. package/examples/basic-json-backend/loopwork.config.js +0 -35
  23. package/examples/basic-json-backend/math.test.ts +0 -29
  24. package/examples/basic-json-backend/math.ts +0 -3
  25. package/examples/basic-json-backend/package.json +0 -15
  26. package/examples/basic-json-backend/quick-start.sh +0 -80
  27. package/loopwork.config.ts +0 -164
  28. package/src/plugins/asana.ts +0 -192
  29. package/src/plugins/cost-tracking.ts +0 -402
  30. package/src/plugins/discord.ts +0 -269
  31. package/src/plugins/everhour.ts +0 -335
  32. package/src/plugins/telegram/bot.ts +0 -517
  33. package/src/plugins/telegram/index.ts +0 -6
  34. package/src/plugins/telegram/notifications.ts +0 -198
  35. package/src/plugins/todoist.ts +0 -261
  36. package/test/backends.test.ts +0 -929
  37. package/test/cli.test.ts +0 -145
  38. package/test/config.test.ts +0 -90
  39. package/test/e2e.test.ts +0 -458
  40. package/test/github-tasks.test.ts +0 -191
  41. package/test/loopwork-config-types.test.ts +0 -288
  42. package/test/monitor.test.ts +0 -123
  43. package/test/plugins.test.ts +0 -1175
  44. package/test/state.test.ts +0 -295
  45. package/test/utils.test.ts +0 -60
  46. package/tsconfig.json +0 -20
@@ -1,929 +0,0 @@
1
- import { describe, test, expect, beforeEach, afterEach } from 'bun:test'
2
- import fs from 'fs'
3
- import path from 'path'
4
- import os from 'os'
5
- import {
6
- createBackend,
7
- GitHubTaskAdapter,
8
- JsonTaskAdapter,
9
- type TaskBackend,
10
- type Task,
11
- type BackendConfig,
12
- } from '../src/backends'
13
-
14
- describe('Backend Factory', () => {
15
- test('creates GitHubTaskAdapter for github type', () => {
16
- const backend = createBackend({ type: 'github' })
17
- expect(backend.name).toBe('github')
18
- expect(backend).toBeInstanceOf(GitHubTaskAdapter)
19
- })
20
-
21
- test('creates JsonTaskAdapter for json type', () => {
22
- const backend = createBackend({ type: 'json', tasksFile: '/tmp/tasks.json' })
23
- expect(backend.name).toBe('json')
24
- expect(backend).toBeInstanceOf(JsonTaskAdapter)
25
- })
26
-
27
- test('throws for unknown backend type', () => {
28
- expect(() => {
29
- createBackend({ type: 'unknown' as any })
30
- }).toThrow('Unknown backend type')
31
- })
32
-
33
- test('passes config to GitHubTaskAdapter', () => {
34
- const backend = createBackend({ type: 'github', repo: 'owner/repo' })
35
- expect(backend.name).toBe('github')
36
- })
37
-
38
- test('passes config to JsonTaskAdapter', () => {
39
- const backend = createBackend({
40
- type: 'json',
41
- tasksFile: '/custom/path/tasks.json',
42
- tasksDir: '/custom/path',
43
- })
44
- expect(backend.name).toBe('json')
45
- })
46
- })
47
-
48
- describe('TaskBackend Interface', () => {
49
- const backends: { name: string; create: () => TaskBackend }[] = [
50
- {
51
- name: 'GitHubTaskAdapter',
52
- create: () => new GitHubTaskAdapter({ type: 'github' }),
53
- },
54
- {
55
- name: 'JsonTaskAdapter',
56
- create: () => new JsonTaskAdapter({ type: 'json', tasksFile: '/tmp/nonexistent.json' }),
57
- },
58
- ]
59
-
60
- for (const { name, create } of backends) {
61
- describe(name, () => {
62
- let backend: TaskBackend
63
-
64
- beforeEach(() => {
65
- backend = create()
66
- })
67
-
68
- test('has name property', () => {
69
- expect(typeof backend.name).toBe('string')
70
- expect(backend.name.length).toBeGreaterThan(0)
71
- })
72
-
73
- test('has findNextTask method', () => {
74
- expect(typeof backend.findNextTask).toBe('function')
75
- })
76
-
77
- test('has getTask method', () => {
78
- expect(typeof backend.getTask).toBe('function')
79
- })
80
-
81
- test('has listPendingTasks method', () => {
82
- expect(typeof backend.listPendingTasks).toBe('function')
83
- })
84
-
85
- test('has countPending method', () => {
86
- expect(typeof backend.countPending).toBe('function')
87
- })
88
-
89
- test('has markInProgress method', () => {
90
- expect(typeof backend.markInProgress).toBe('function')
91
- })
92
-
93
- test('has markCompleted method', () => {
94
- expect(typeof backend.markCompleted).toBe('function')
95
- })
96
-
97
- test('has markFailed method', () => {
98
- expect(typeof backend.markFailed).toBe('function')
99
- })
100
-
101
- test('has resetToPending method', () => {
102
- expect(typeof backend.resetToPending).toBe('function')
103
- })
104
- })
105
- }
106
- })
107
-
108
- describe('JsonTaskAdapter', () => {
109
- let tempDir: string
110
- let tasksFile: string
111
- let adapter: JsonTaskAdapter
112
-
113
- beforeEach(() => {
114
- tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ralph-json-test-'))
115
- tasksFile = path.join(tempDir, 'tasks.json')
116
- adapter = new JsonTaskAdapter({
117
- type: 'json',
118
- tasksFile,
119
- tasksDir: tempDir,
120
- })
121
- })
122
-
123
- afterEach(() => {
124
- fs.rmSync(tempDir, { recursive: true, force: true })
125
- })
126
-
127
- test('returns null when tasks file does not exist', async () => {
128
- const task = await adapter.findNextTask()
129
- expect(task).toBeNull()
130
- })
131
-
132
- test('returns empty array when tasks file does not exist', async () => {
133
- const tasks = await adapter.listPendingTasks()
134
- expect(tasks).toEqual([])
135
- })
136
-
137
- test('returns 0 count when tasks file does not exist', async () => {
138
- const count = await adapter.countPending()
139
- expect(count).toBe(0)
140
- })
141
-
142
- describe('with tasks file', () => {
143
- beforeEach(() => {
144
- const tasksData = {
145
- tasks: [
146
- { id: 'TASK-001-01', status: 'pending', priority: 'high', feature: 'auth' },
147
- { id: 'TASK-001-02', status: 'pending', priority: 'low' },
148
- { id: 'TASK-002-01', status: 'completed' },
149
- { id: 'TASK-003-01', status: 'in-progress' },
150
- ],
151
- features: {
152
- auth: { name: 'Authentication' },
153
- },
154
- }
155
- fs.writeFileSync(tasksFile, JSON.stringify(tasksData, null, 2))
156
-
157
- // Create PRD files
158
- fs.writeFileSync(
159
- path.join(tempDir, 'TASK-001-01.md'),
160
- '# TASK-001-01: Implement login\n\n## Goal\nAdd login functionality'
161
- )
162
- fs.writeFileSync(
163
- path.join(tempDir, 'TASK-001-02.md'),
164
- '# TASK-001-02: Add logout\n\n## Goal\nAdd logout button'
165
- )
166
- })
167
-
168
- test('finds next pending task', async () => {
169
- const task = await adapter.findNextTask()
170
- expect(task).not.toBeNull()
171
- expect(task!.status).toBe('pending')
172
- })
173
-
174
- test('returns high priority tasks first', async () => {
175
- const task = await adapter.findNextTask()
176
- expect(task!.id).toBe('TASK-001-01')
177
- expect(task!.priority).toBe('high')
178
- })
179
-
180
- test('filters by feature', async () => {
181
- const tasks = await adapter.listPendingTasks({ feature: 'auth' })
182
- expect(tasks.length).toBe(1)
183
- expect(tasks[0].feature).toBe('auth')
184
- })
185
-
186
- test('counts pending tasks', async () => {
187
- const count = await adapter.countPending()
188
- expect(count).toBe(2)
189
- })
190
-
191
- test('gets specific task by ID', async () => {
192
- const task = await adapter.getTask('TASK-001-01')
193
- expect(task).not.toBeNull()
194
- expect(task!.id).toBe('TASK-001-01')
195
- expect(task!.title).toBe('TASK-001-01: Implement login')
196
- })
197
-
198
- test('returns null for non-existent task', async () => {
199
- const task = await adapter.getTask('TASK-999-99')
200
- expect(task).toBeNull()
201
- })
202
-
203
- test('loads PRD content as description', async () => {
204
- const task = await adapter.getTask('TASK-001-01')
205
- expect(task!.description).toContain('## Goal')
206
- expect(task!.description).toContain('Add login functionality')
207
- })
208
-
209
- test('marks task in progress', async () => {
210
- const result = await adapter.markInProgress('TASK-001-01')
211
- expect(result.success).toBe(true)
212
-
213
- const data = JSON.parse(fs.readFileSync(tasksFile, 'utf-8'))
214
- const task = data.tasks.find((t: any) => t.id === 'TASK-001-01')
215
- expect(task.status).toBe('in-progress')
216
- })
217
-
218
- test('marks task completed', async () => {
219
- const result = await adapter.markCompleted('TASK-001-01')
220
- expect(result.success).toBe(true)
221
-
222
- const data = JSON.parse(fs.readFileSync(tasksFile, 'utf-8'))
223
- const task = data.tasks.find((t: any) => t.id === 'TASK-001-01')
224
- expect(task.status).toBe('completed')
225
- })
226
-
227
- test('marks task failed', async () => {
228
- const result = await adapter.markFailed('TASK-001-01', 'Test error')
229
- expect(result.success).toBe(true)
230
-
231
- const data = JSON.parse(fs.readFileSync(tasksFile, 'utf-8'))
232
- const task = data.tasks.find((t: any) => t.id === 'TASK-001-01')
233
- expect(task.status).toBe('failed')
234
- })
235
-
236
- test('resets task to pending', async () => {
237
- await adapter.markFailed('TASK-001-01', 'Error')
238
- const result = await adapter.resetToPending('TASK-001-01')
239
- expect(result.success).toBe(true)
240
-
241
- const data = JSON.parse(fs.readFileSync(tasksFile, 'utf-8'))
242
- const task = data.tasks.find((t: any) => t.id === 'TASK-001-01')
243
- expect(task.status).toBe('pending')
244
- })
245
-
246
- test('adds comment to log file', async () => {
247
- const result = await adapter.addComment!('TASK-001-01', 'Test comment')
248
- expect(result.success).toBe(true)
249
-
250
- const logFile = path.join(tempDir, 'TASK-001-01.log')
251
- expect(fs.existsSync(logFile)).toBe(true)
252
- const content = fs.readFileSync(logFile, 'utf-8')
253
- expect(content).toContain('Test comment')
254
- })
255
-
256
- test('returns error for non-existent task update', async () => {
257
- const result = await adapter.markInProgress('TASK-999-99')
258
- expect(result.success).toBe(false)
259
- expect(result.error).toContain('not found')
260
- })
261
- })
262
- })
263
-
264
- describe('GitHubTaskAdapter', () => {
265
- let adapter: GitHubTaskAdapter
266
-
267
- beforeEach(() => {
268
- adapter = new GitHubTaskAdapter({ type: 'github', repo: 'test/repo' })
269
- })
270
-
271
- test('has correct name', () => {
272
- expect(adapter.name).toBe('github')
273
- })
274
-
275
- // Note: Full GitHub integration tests would require mocking gh CLI
276
- // These tests verify the adapter structure and basic logic
277
-
278
- test('adaptIssue extracts task ID from title', () => {
279
- const issue = {
280
- number: 123,
281
- title: 'TASK-025-01: Add health score',
282
- body: 'PRD content',
283
- state: 'open' as const,
284
- labels: [{ name: 'loopwork-task' }, { name: 'loopwork:pending' }],
285
- url: 'https://github.com/test/repo/issues/123',
286
- }
287
-
288
- const task = (adapter as any).adaptIssue(issue)
289
- expect(task.id).toBe('TASK-025-01')
290
- expect(task.title).toBe('TASK-025-01: Add health score')
291
- expect(task.description).toBe('PRD content')
292
- expect(task.status).toBe('pending')
293
- })
294
-
295
- test('adaptIssue generates GH-{number} for non-standard titles', () => {
296
- const issue = {
297
- number: 456,
298
- title: 'Fix bug in login',
299
- body: 'Bug description',
300
- state: 'open' as const,
301
- labels: [{ name: 'loopwork-task' }],
302
- url: 'https://github.com/test/repo/issues/456',
303
- }
304
-
305
- const task = (adapter as any).adaptIssue(issue)
306
- expect(task.id).toBe('GH-456')
307
- })
308
-
309
- test('adaptIssue detects priority from labels', () => {
310
- const highPriority = {
311
- number: 1,
312
- title: 'TASK-001-01: High priority',
313
- body: '',
314
- state: 'open' as const,
315
- labels: [{ name: 'loopwork-task' }, { name: 'priority:high' }],
316
- url: 'https://github.com/test/repo/issues/1',
317
- }
318
-
319
- const lowPriority = {
320
- number: 2,
321
- title: 'TASK-001-02: Low priority',
322
- body: '',
323
- state: 'open' as const,
324
- labels: [{ name: 'loopwork-task' }, { name: 'priority:low' }],
325
- url: 'https://github.com/test/repo/issues/2',
326
- }
327
-
328
- expect((adapter as any).adaptIssue(highPriority).priority).toBe('high')
329
- expect((adapter as any).adaptIssue(lowPriority).priority).toBe('low')
330
- })
331
-
332
- test('adaptIssue extracts feature from labels', () => {
333
- const issue = {
334
- number: 1,
335
- title: 'TASK-001-01: Feature task',
336
- body: '',
337
- state: 'open' as const,
338
- labels: [{ name: 'loopwork-task' }, { name: 'feat:authentication' }],
339
- url: 'https://github.com/test/repo/issues/1',
340
- }
341
-
342
- const task = (adapter as any).adaptIssue(issue)
343
- expect(task.feature).toBe('authentication')
344
- })
345
-
346
- test('extractIssueNumber handles GH-{number} format', () => {
347
- expect((adapter as any).extractIssueNumber('GH-123')).toBe(123)
348
- expect((adapter as any).extractIssueNumber('GH-456')).toBe(456)
349
- })
350
-
351
- test('extractIssueNumber handles plain numbers', () => {
352
- expect((adapter as any).extractIssueNumber('123')).toBe(123)
353
- expect((adapter as any).extractIssueNumber('456')).toBe(456)
354
- })
355
-
356
- test('extractIssueNumber returns null for invalid input', () => {
357
- expect((adapter as any).extractIssueNumber('invalid')).toBeNull()
358
- expect((adapter as any).extractIssueNumber('TASK-001-01')).toBeNull()
359
- })
360
- })
361
-
362
- describe('Task Interface', () => {
363
- test('Task has required fields', () => {
364
- const task: Task = {
365
- id: 'TASK-001-01',
366
- title: 'Test task',
367
- description: 'Task description',
368
- status: 'pending',
369
- priority: 'medium',
370
- }
371
-
372
- expect(task.id).toBeDefined()
373
- expect(task.title).toBeDefined()
374
- expect(task.description).toBeDefined()
375
- expect(task.status).toBeDefined()
376
- expect(task.priority).toBeDefined()
377
- })
378
-
379
- test('Task supports optional fields', () => {
380
- const task: Task = {
381
- id: 'TASK-001-01',
382
- title: 'Test task',
383
- description: 'Description',
384
- status: 'pending',
385
- priority: 'high',
386
- feature: 'auth',
387
- metadata: {
388
- issueNumber: 123,
389
- url: 'https://example.com',
390
- },
391
- }
392
-
393
- expect(task.feature).toBe('auth')
394
- expect(task.metadata?.issueNumber).toBe(123)
395
- })
396
-
397
- test('TaskStatus has valid values', () => {
398
- const validStatuses = ['pending', 'in-progress', 'completed', 'failed']
399
- const task: Task = {
400
- id: 'test',
401
- title: 'test',
402
- description: '',
403
- status: 'pending',
404
- priority: 'medium',
405
- }
406
-
407
- for (const status of validStatuses) {
408
- task.status = status as Task['status']
409
- expect(validStatuses).toContain(task.status)
410
- }
411
- })
412
-
413
- test('Priority has valid values', () => {
414
- const validPriorities = ['high', 'medium', 'low']
415
- const task: Task = {
416
- id: 'test',
417
- title: 'test',
418
- description: '',
419
- status: 'pending',
420
- priority: 'medium',
421
- }
422
-
423
- for (const priority of validPriorities) {
424
- task.priority = priority as Task['priority']
425
- expect(validPriorities).toContain(task.priority)
426
- }
427
- })
428
- })
429
-
430
- describe('Health Check (ping)', () => {
431
- describe('JsonTaskAdapter ping', () => {
432
- let tempDir: string
433
-
434
- beforeEach(() => {
435
- tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ralph-ping-test-'))
436
- })
437
-
438
- afterEach(() => {
439
- fs.rmSync(tempDir, { recursive: true, force: true })
440
- })
441
-
442
- test('returns ok:true when tasks file exists and is valid', async () => {
443
- const tasksFile = path.join(tempDir, 'tasks.json')
444
- fs.writeFileSync(tasksFile, JSON.stringify({ tasks: [] }))
445
-
446
- const adapter = new JsonTaskAdapter({ type: 'json', tasksFile, tasksDir: tempDir })
447
- const result = await adapter.ping()
448
-
449
- expect(result.ok).toBe(true)
450
- expect(result.latencyMs).toBeGreaterThanOrEqual(0)
451
- expect(result.error).toBeUndefined()
452
- })
453
-
454
- test('returns ok:false when tasks file does not exist', async () => {
455
- const adapter = new JsonTaskAdapter({
456
- type: 'json',
457
- tasksFile: path.join(tempDir, 'nonexistent.json'),
458
- tasksDir: tempDir,
459
- })
460
- const result = await adapter.ping()
461
-
462
- expect(result.ok).toBe(false)
463
- expect(result.error).toContain('not found')
464
- })
465
-
466
- test('returns ok:false when tasks file is invalid JSON', async () => {
467
- const tasksFile = path.join(tempDir, 'invalid.json')
468
- fs.writeFileSync(tasksFile, 'not valid json {{{')
469
-
470
- const adapter = new JsonTaskAdapter({ type: 'json', tasksFile, tasksDir: tempDir })
471
- const result = await adapter.ping()
472
-
473
- expect(result.ok).toBe(false)
474
- expect(result.error).toBeDefined()
475
- })
476
-
477
- test('latencyMs is measured', async () => {
478
- const tasksFile = path.join(tempDir, 'tasks.json')
479
- fs.writeFileSync(tasksFile, JSON.stringify({ tasks: [] }))
480
-
481
- const adapter = new JsonTaskAdapter({ type: 'json', tasksFile, tasksDir: tempDir })
482
- const result = await adapter.ping()
483
-
484
- expect(typeof result.latencyMs).toBe('number')
485
- expect(result.latencyMs).toBeGreaterThanOrEqual(0)
486
- })
487
- })
488
-
489
- describe('GitHubTaskAdapter ping', () => {
490
- test('has ping method', () => {
491
- const adapter = new GitHubTaskAdapter({ type: 'github' })
492
- expect(typeof adapter.ping).toBe('function')
493
- })
494
-
495
- test('returns result with ok, latencyMs, and optional error', async () => {
496
- const adapter = new GitHubTaskAdapter({ type: 'github' })
497
- const result = await adapter.ping()
498
-
499
- expect(typeof result.ok).toBe('boolean')
500
- expect(typeof result.latencyMs).toBe('number')
501
- // error is optional
502
- })
503
- })
504
- })
505
-
506
- describe('Error Scenarios', () => {
507
- describe('JsonTaskAdapter error handling', () => {
508
- let tempDir: string
509
-
510
- beforeEach(() => {
511
- tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ralph-error-test-'))
512
- })
513
-
514
- afterEach(() => {
515
- fs.rmSync(tempDir, { recursive: true, force: true })
516
- })
517
-
518
- test('handles corrupted JSON gracefully', async () => {
519
- const tasksFile = path.join(tempDir, 'tasks.json')
520
- fs.writeFileSync(tasksFile, '{ invalid json }}}')
521
-
522
- const adapter = new JsonTaskAdapter({ type: 'json', tasksFile, tasksDir: tempDir })
523
-
524
- // Should not throw, should return empty/null
525
- const tasks = await adapter.listPendingTasks()
526
- expect(tasks).toEqual([])
527
-
528
- const task = await adapter.findNextTask()
529
- expect(task).toBeNull()
530
-
531
- const count = await adapter.countPending()
532
- expect(count).toBe(0)
533
- })
534
-
535
- test('handles missing PRD file gracefully', async () => {
536
- const tasksFile = path.join(tempDir, 'tasks.json')
537
- fs.writeFileSync(tasksFile, JSON.stringify({
538
- tasks: [{ id: 'TASK-001-01', status: 'pending' }],
539
- }))
540
- // Note: PRD file TASK-001-01.md is NOT created
541
-
542
- const adapter = new JsonTaskAdapter({ type: 'json', tasksFile, tasksDir: tempDir })
543
- const task = await adapter.getTask('TASK-001-01')
544
-
545
- expect(task).not.toBeNull()
546
- expect(task!.id).toBe('TASK-001-01')
547
- expect(task!.description).toBe('') // Empty when PRD missing
548
- })
549
-
550
- test('handles update to non-existent task', async () => {
551
- const tasksFile = path.join(tempDir, 'tasks.json')
552
- fs.writeFileSync(tasksFile, JSON.stringify({ tasks: [] }))
553
-
554
- const adapter = new JsonTaskAdapter({ type: 'json', tasksFile, tasksDir: tempDir })
555
- const result = await adapter.markInProgress('TASK-999-99')
556
-
557
- expect(result.success).toBe(false)
558
- expect(result.error).toContain('not found')
559
- })
560
-
561
- test('file locking prevents concurrent writes', async () => {
562
- const tasksFile = path.join(tempDir, 'tasks.json')
563
- fs.writeFileSync(tasksFile, JSON.stringify({
564
- tasks: [
565
- { id: 'TASK-001-01', status: 'pending' },
566
- { id: 'TASK-001-02', status: 'pending' },
567
- ],
568
- }))
569
-
570
- const adapter = new JsonTaskAdapter({ type: 'json', tasksFile, tasksDir: tempDir })
571
-
572
- // Run multiple updates concurrently
573
- const results = await Promise.all([
574
- adapter.markInProgress('TASK-001-01'),
575
- adapter.markInProgress('TASK-001-02'),
576
- ])
577
-
578
- // Both should succeed (locking should serialize them)
579
- const successCount = results.filter(r => r.success).length
580
- expect(successCount).toBeGreaterThan(0)
581
- })
582
- })
583
-
584
- describe('GitHubTaskAdapter error handling', () => {
585
- test('returns error for invalid task ID', async () => {
586
- const adapter = new GitHubTaskAdapter({ type: 'github' })
587
-
588
- const result = await adapter.markInProgress('invalid-task-id')
589
- expect(result.success).toBe(false)
590
- expect(result.error).toContain('Invalid task ID')
591
- })
592
-
593
- test('extractIssueNumber handles various formats', () => {
594
- const adapter = new GitHubTaskAdapter({ type: 'github' })
595
-
596
- expect((adapter as any).extractIssueNumber('GH-123')).toBe(123)
597
- expect((adapter as any).extractIssueNumber('456')).toBe(456)
598
- expect((adapter as any).extractIssueNumber('invalid')).toBeNull()
599
- expect((adapter as any).extractIssueNumber('TASK-001-01')).toBeNull()
600
- })
601
-
602
- test('isRetryableError identifies retryable errors', () => {
603
- const adapter = new GitHubTaskAdapter({ type: 'github' })
604
-
605
- expect((adapter as any).isRetryableError({ message: 'network timeout' })).toBe(true)
606
- expect((adapter as any).isRetryableError({ message: 'ECONNRESET' })).toBe(true)
607
- expect((adapter as any).isRetryableError({ message: 'rate limit exceeded' })).toBe(true)
608
- expect((adapter as any).isRetryableError({ message: '502 Bad Gateway' })).toBe(true)
609
- expect((adapter as any).isRetryableError({ message: 'normal error' })).toBe(false)
610
- })
611
- })
612
- })
613
-
614
- describe('Sub-tasks and Dependencies', () => {
615
- describe('JsonTaskAdapter sub-tasks', () => {
616
- let tempDir: string
617
- let tasksFile: string
618
- let adapter: JsonTaskAdapter
619
-
620
- beforeEach(() => {
621
- tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ralph-subtask-test-'))
622
- tasksFile = path.join(tempDir, 'tasks.json')
623
- adapter = new JsonTaskAdapter({ type: 'json', tasksFile, tasksDir: tempDir })
624
-
625
- // Create tasks with sub-task relationships
626
- const tasksData = {
627
- tasks: [
628
- { id: 'TASK-001-01', status: 'pending', priority: 'high' },
629
- { id: 'TASK-001-01a', status: 'pending', priority: 'medium', parentId: 'TASK-001-01' },
630
- { id: 'TASK-001-01b', status: 'completed', priority: 'low', parentId: 'TASK-001-01' },
631
- { id: 'TASK-002-01', status: 'pending', priority: 'medium' },
632
- ],
633
- }
634
- fs.writeFileSync(tasksFile, JSON.stringify(tasksData, null, 2))
635
- })
636
-
637
- afterEach(() => {
638
- fs.rmSync(tempDir, { recursive: true, force: true })
639
- })
640
-
641
- test('getSubTasks returns sub-tasks of a parent', async () => {
642
- const subtasks = await adapter.getSubTasks('TASK-001-01')
643
- expect(subtasks.length).toBe(2)
644
- expect(subtasks.map(t => t.id).sort()).toEqual(['TASK-001-01a', 'TASK-001-01b'])
645
- })
646
-
647
- test('getSubTasks returns empty for task without sub-tasks', async () => {
648
- const subtasks = await adapter.getSubTasks('TASK-002-01')
649
- expect(subtasks).toEqual([])
650
- })
651
-
652
- test('listPendingTasks can filter by parentId', async () => {
653
- const tasks = await adapter.listPendingTasks({ parentId: 'TASK-001-01' })
654
- expect(tasks.length).toBe(1)
655
- expect(tasks[0].id).toBe('TASK-001-01a')
656
- })
657
-
658
- test('listPendingTasks can filter to top-level only', async () => {
659
- const tasks = await adapter.listPendingTasks({ topLevelOnly: true })
660
- expect(tasks.every(t => !t.parentId)).toBe(true)
661
- expect(tasks.length).toBe(2) // TASK-001-01 and TASK-002-01
662
- })
663
-
664
- test('createSubTask creates a sub-task', async () => {
665
- const subtask = await adapter.createSubTask!('TASK-002-01', {
666
- title: 'Sub-task title',
667
- description: 'Sub-task description',
668
- priority: 'high',
669
- })
670
-
671
- expect(subtask.parentId).toBe('TASK-002-01')
672
- expect(subtask.id).toBe('TASK-002-01a')
673
- expect(subtask.status).toBe('pending')
674
-
675
- // Verify it was saved
676
- const loaded = await adapter.getTask('TASK-002-01a')
677
- expect(loaded).not.toBeNull()
678
- expect(loaded!.parentId).toBe('TASK-002-01')
679
- })
680
-
681
- test('Task includes parentId when loaded', async () => {
682
- const task = await adapter.getTask('TASK-001-01a')
683
- expect(task).not.toBeNull()
684
- expect(task!.parentId).toBe('TASK-001-01')
685
- })
686
- })
687
-
688
- describe('JsonTaskAdapter dependencies', () => {
689
- let tempDir: string
690
- let tasksFile: string
691
- let adapter: JsonTaskAdapter
692
-
693
- beforeEach(() => {
694
- tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ralph-deps-test-'))
695
- tasksFile = path.join(tempDir, 'tasks.json')
696
- adapter = new JsonTaskAdapter({ type: 'json', tasksFile, tasksDir: tempDir })
697
-
698
- // Create tasks with dependencies
699
- const tasksData = {
700
- tasks: [
701
- { id: 'TASK-001-01', status: 'completed', priority: 'high' },
702
- { id: 'TASK-001-02', status: 'completed', priority: 'medium' },
703
- { id: 'TASK-002-01', status: 'pending', priority: 'high', dependsOn: ['TASK-001-01', 'TASK-001-02'] },
704
- { id: 'TASK-003-01', status: 'pending', priority: 'medium', dependsOn: ['TASK-001-01'] },
705
- { id: 'TASK-004-01', status: 'pending', priority: 'low' }, // No dependencies
706
- ],
707
- }
708
- fs.writeFileSync(tasksFile, JSON.stringify(tasksData, null, 2))
709
- })
710
-
711
- afterEach(() => {
712
- fs.rmSync(tempDir, { recursive: true, force: true })
713
- })
714
-
715
- test('getDependencies returns tasks this task depends on', async () => {
716
- const deps = await adapter.getDependencies('TASK-002-01')
717
- expect(deps.length).toBe(2)
718
- expect(deps.map(t => t.id).sort()).toEqual(['TASK-001-01', 'TASK-001-02'])
719
- })
720
-
721
- test('getDependencies returns empty for task without dependencies', async () => {
722
- const deps = await adapter.getDependencies('TASK-004-01')
723
- expect(deps).toEqual([])
724
- })
725
-
726
- test('getDependents returns tasks that depend on this task', async () => {
727
- const dependents = await adapter.getDependents('TASK-001-01')
728
- expect(dependents.length).toBe(2)
729
- expect(dependents.map(t => t.id).sort()).toEqual(['TASK-002-01', 'TASK-003-01'])
730
- })
731
-
732
- test('areDependenciesMet returns true when all deps completed', async () => {
733
- const met = await adapter.areDependenciesMet('TASK-002-01')
734
- expect(met).toBe(true)
735
- })
736
-
737
- test('areDependenciesMet returns false when deps not completed', async () => {
738
- // Update a dependency to pending
739
- const data = JSON.parse(fs.readFileSync(tasksFile, 'utf-8'))
740
- const task = data.tasks.find((t: any) => t.id === 'TASK-001-01')
741
- task.status = 'pending'
742
- fs.writeFileSync(tasksFile, JSON.stringify(data, null, 2))
743
-
744
- const met = await adapter.areDependenciesMet('TASK-002-01')
745
- expect(met).toBe(false)
746
- })
747
-
748
- test('areDependenciesMet returns true for task without deps', async () => {
749
- const met = await adapter.areDependenciesMet('TASK-004-01')
750
- expect(met).toBe(true)
751
- })
752
-
753
- test('listPendingTasks excludes blocked tasks by default', async () => {
754
- // Set one dependency to pending so TASK-002-01 is blocked
755
- const data = JSON.parse(fs.readFileSync(tasksFile, 'utf-8'))
756
- const task = data.tasks.find((t: any) => t.id === 'TASK-001-01')
757
- task.status = 'pending'
758
- fs.writeFileSync(tasksFile, JSON.stringify(data, null, 2))
759
-
760
- const tasks = await adapter.listPendingTasks()
761
- const ids = tasks.map(t => t.id)
762
-
763
- // TASK-002-01 has unmet dep (TASK-001-01 pending), TASK-003-01 also has unmet dep
764
- expect(ids).not.toContain('TASK-002-01')
765
- expect(ids).not.toContain('TASK-003-01')
766
- expect(ids).toContain('TASK-001-01') // Now pending
767
- expect(ids).toContain('TASK-004-01') // No deps
768
- })
769
-
770
- test('listPendingTasks includes blocked tasks when option set', async () => {
771
- // Set one dependency to pending so TASK-002-01 is blocked
772
- const data = JSON.parse(fs.readFileSync(tasksFile, 'utf-8'))
773
- const task = data.tasks.find((t: any) => t.id === 'TASK-001-01')
774
- task.status = 'pending'
775
- fs.writeFileSync(tasksFile, JSON.stringify(data, null, 2))
776
-
777
- const tasks = await adapter.listPendingTasks({ includeBlocked: true })
778
- const ids = tasks.map(t => t.id)
779
-
780
- expect(ids).toContain('TASK-002-01') // Blocked but included
781
- expect(ids).toContain('TASK-003-01') // Blocked but included
782
- })
783
-
784
- test('addDependency adds a dependency', async () => {
785
- const result = await adapter.addDependency!('TASK-004-01', 'TASK-001-01')
786
- expect(result.success).toBe(true)
787
-
788
- const task = await adapter.getTask('TASK-004-01')
789
- expect(task!.dependsOn).toContain('TASK-001-01')
790
- })
791
-
792
- test('addDependency is idempotent', async () => {
793
- await adapter.addDependency!('TASK-004-01', 'TASK-001-01')
794
- const result = await adapter.addDependency!('TASK-004-01', 'TASK-001-01')
795
- expect(result.success).toBe(true)
796
-
797
- const task = await adapter.getTask('TASK-004-01')
798
- expect(task!.dependsOn?.filter(d => d === 'TASK-001-01').length).toBe(1)
799
- })
800
-
801
- test('removeDependency removes a dependency', async () => {
802
- const result = await adapter.removeDependency!('TASK-002-01', 'TASK-001-01')
803
- expect(result.success).toBe(true)
804
-
805
- const task = await adapter.getTask('TASK-002-01')
806
- expect(task!.dependsOn).not.toContain('TASK-001-01')
807
- expect(task!.dependsOn).toContain('TASK-001-02')
808
- })
809
-
810
- test('Task includes dependsOn when loaded', async () => {
811
- const task = await adapter.getTask('TASK-002-01')
812
- expect(task).not.toBeNull()
813
- expect(task!.dependsOn).toEqual(['TASK-001-01', 'TASK-001-02'])
814
- })
815
- })
816
-
817
- describe('GitHubTaskAdapter sub-tasks and dependencies', () => {
818
- let adapter: GitHubTaskAdapter
819
-
820
- beforeEach(() => {
821
- adapter = new GitHubTaskAdapter({ type: 'github' })
822
- })
823
-
824
- test('adaptIssue extracts parentId from body', () => {
825
- const issue = {
826
- number: 123,
827
- title: 'TASK-001-01a: Sub task',
828
- body: 'Parent: #100\n\nSub task description',
829
- state: 'open' as const,
830
- labels: [{ name: 'loopwork-task' }, { name: 'loopwork:sub-task' }],
831
- url: 'https://github.com/test/repo/issues/123',
832
- }
833
-
834
- const task = (adapter as any).adaptIssue(issue)
835
- expect(task.parentId).toBe('GH-100')
836
- })
837
-
838
- test('adaptIssue extracts dependsOn from body', () => {
839
- const issue = {
840
- number: 123,
841
- title: 'TASK-001-01: Main task',
842
- body: 'Depends on: #50, #51, #52\n\nTask description',
843
- state: 'open' as const,
844
- labels: [{ name: 'loopwork-task' }],
845
- url: 'https://github.com/test/repo/issues/123',
846
- }
847
-
848
- const task = (adapter as any).adaptIssue(issue)
849
- expect(task.dependsOn).toEqual(['GH-50', 'GH-51', 'GH-52'])
850
- })
851
-
852
- test('adaptIssue handles task ID format in dependencies', () => {
853
- const issue = {
854
- number: 123,
855
- title: 'TASK-001-01: Main task',
856
- body: 'Depends on: TASK-001-02, TASK-001-03\n\nTask description',
857
- state: 'open' as const,
858
- labels: [{ name: 'loopwork-task' }],
859
- url: 'https://github.com/test/repo/issues/123',
860
- }
861
-
862
- const task = (adapter as any).adaptIssue(issue)
863
- expect(task.dependsOn).toEqual(['TASK-001-02', 'TASK-001-03'])
864
- })
865
-
866
- test('has getSubTasks method', () => {
867
- expect(typeof adapter.getSubTasks).toBe('function')
868
- })
869
-
870
- test('has getDependencies method', () => {
871
- expect(typeof adapter.getDependencies).toBe('function')
872
- })
873
-
874
- test('has getDependents method', () => {
875
- expect(typeof adapter.getDependents).toBe('function')
876
- })
877
-
878
- test('has areDependenciesMet method', () => {
879
- expect(typeof adapter.areDependenciesMet).toBe('function')
880
- })
881
-
882
- test('has createSubTask method', () => {
883
- expect(typeof adapter.createSubTask).toBe('function')
884
- })
885
-
886
- test('has addDependency method', () => {
887
- expect(typeof adapter.addDependency).toBe('function')
888
- })
889
-
890
- test('has removeDependency method', () => {
891
- expect(typeof adapter.removeDependency).toBe('function')
892
- })
893
-
894
- test('extractIssueNumber handles #123 format', () => {
895
- expect((adapter as any).extractIssueNumber('#123')).toBe(123)
896
- expect((adapter as any).extractIssueNumber('#456')).toBe(456)
897
- })
898
- })
899
-
900
- describe('Task Interface with sub-tasks and dependencies', () => {
901
- test('Task supports parentId and dependsOn fields', () => {
902
- const task: Task = {
903
- id: 'TASK-001-01a',
904
- title: 'Sub task',
905
- description: 'Description',
906
- status: 'pending',
907
- priority: 'medium',
908
- parentId: 'TASK-001-01',
909
- dependsOn: ['TASK-001-00', 'TASK-001-00b'],
910
- }
911
-
912
- expect(task.parentId).toBe('TASK-001-01')
913
- expect(task.dependsOn).toEqual(['TASK-001-00', 'TASK-001-00b'])
914
- })
915
-
916
- test('Task parentId and dependsOn are optional', () => {
917
- const task: Task = {
918
- id: 'TASK-001-01',
919
- title: 'Regular task',
920
- description: 'Description',
921
- status: 'pending',
922
- priority: 'medium',
923
- }
924
-
925
- expect(task.parentId).toBeUndefined()
926
- expect(task.dependsOn).toBeUndefined()
927
- })
928
- })
929
- })