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.
- package/bin/loopwork +0 -0
- package/package.json +48 -4
- package/src/backends/github.ts +6 -3
- package/src/backends/json.ts +28 -10
- package/src/commands/run.ts +2 -2
- package/src/contracts/config.ts +3 -75
- package/src/contracts/index.ts +0 -6
- package/src/core/cli.ts +25 -16
- package/src/core/state.ts +10 -4
- package/src/core/utils.ts +10 -4
- package/src/monitor/index.ts +56 -34
- package/src/plugins/index.ts +9 -131
- package/examples/README.md +0 -70
- package/examples/basic-json-backend/.specs/tasks/TASK-001.md +0 -22
- package/examples/basic-json-backend/.specs/tasks/TASK-002.md +0 -23
- package/examples/basic-json-backend/.specs/tasks/TASK-003.md +0 -37
- package/examples/basic-json-backend/.specs/tasks/tasks.json +0 -19
- package/examples/basic-json-backend/README.md +0 -32
- package/examples/basic-json-backend/TESTING.md +0 -184
- package/examples/basic-json-backend/hello.test.ts +0 -9
- package/examples/basic-json-backend/hello.ts +0 -3
- package/examples/basic-json-backend/loopwork.config.js +0 -35
- package/examples/basic-json-backend/math.test.ts +0 -29
- package/examples/basic-json-backend/math.ts +0 -3
- package/examples/basic-json-backend/package.json +0 -15
- package/examples/basic-json-backend/quick-start.sh +0 -80
- package/loopwork.config.ts +0 -164
- package/src/plugins/asana.ts +0 -192
- package/src/plugins/cost-tracking.ts +0 -402
- package/src/plugins/discord.ts +0 -269
- package/src/plugins/everhour.ts +0 -335
- package/src/plugins/telegram/bot.ts +0 -517
- package/src/plugins/telegram/index.ts +0 -6
- package/src/plugins/telegram/notifications.ts +0 -198
- package/src/plugins/todoist.ts +0 -261
- package/test/backends.test.ts +0 -929
- package/test/cli.test.ts +0 -145
- package/test/config.test.ts +0 -90
- package/test/e2e.test.ts +0 -458
- package/test/github-tasks.test.ts +0 -191
- package/test/loopwork-config-types.test.ts +0 -288
- package/test/monitor.test.ts +0 -123
- package/test/plugins.test.ts +0 -1175
- package/test/state.test.ts +0 -295
- package/test/utils.test.ts +0 -60
- package/tsconfig.json +0 -20
package/test/backends.test.ts
DELETED
|
@@ -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
|
-
})
|