tlc-claude-code 2.4.10 → 2.5.0

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.
@@ -0,0 +1,805 @@
1
+ /**
2
+ * @file plan-sync.test.js
3
+ * @description Tests for plan-to-issues sync (Phase 97, Task 3).
4
+ *
5
+ * Tests the module that parses PLAN.md, creates GitHub issues for tasks,
6
+ * adds them to a project board, and writes issue markers back into the plan.
7
+ *
8
+ * All external dependencies (ghClient, ghProjects, fs) are mocked via DI.
9
+ *
10
+ * TDD: RED phase — these tests are written BEFORE the implementation.
11
+ */
12
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
13
+ import {
14
+ parsePlanTasks,
15
+ injectIssueMarkers,
16
+ syncPlan,
17
+ updateTaskStatuses,
18
+ } from './plan-sync.js';
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Fixtures
22
+ // ---------------------------------------------------------------------------
23
+
24
+ const SAMPLE_PLAN = `# Phase 97: GitHub Deep Integration - Plan
25
+
26
+ ## Overview
27
+
28
+ Bidirectional sync between TLC and GitHub.
29
+
30
+ ## Tasks
31
+
32
+ ### Task 1: GitHub Client — Issues + PRs [ ]
33
+
34
+ **Goal:** Module that wraps \`gh\` CLI for issue and PR operations.
35
+
36
+ **Acceptance Criteria:**
37
+ - [ ] createIssue works
38
+ - [ ] closeIssue works
39
+
40
+ ---
41
+
42
+ ### Task 2: GitHub Client — Projects V2 [>@alice]
43
+
44
+ **Goal:** Module for GitHub Projects V2 via GraphQL.
45
+
46
+ **Acceptance Criteria:**
47
+ - [ ] discoverProject works
48
+
49
+ ---
50
+
51
+ ### Task 3: Plan-to-Issues Sync [ ]
52
+
53
+ **Goal:** Sync plan tasks to GitHub issues.
54
+
55
+ **Acceptance Criteria:**
56
+ - [ ] syncPlan works
57
+
58
+ ---
59
+
60
+ ### Task 4: Hook Integration [x]
61
+
62
+ **Goal:** Wire sync into the TLC command flow.
63
+
64
+ **Acceptance Criteria:**
65
+ - [x] Hooks integrated
66
+
67
+ ---
68
+
69
+ ### Task 5: Config + Setup [x@bob]
70
+
71
+ **Goal:** Configuration management.
72
+
73
+ **Acceptance Criteria:**
74
+ - [x] Config works
75
+ `;
76
+
77
+ const PLAN_WITH_MARKERS = `# Phase 42: Test Phase - Plan
78
+
79
+ ## Tasks
80
+
81
+ ### Task 1: First Task [ ] <!-- #101 -->
82
+
83
+ **Goal:** Do first thing.
84
+
85
+ ---
86
+
87
+ ### Task 2: Second Task [>@carol] <!-- #102 -->
88
+
89
+ **Goal:** Do second thing.
90
+
91
+ ---
92
+
93
+ ### Task 3: Third Task [ ]
94
+
95
+ **Goal:** Do third thing.
96
+ `;
97
+
98
+ const PLAN_NO_TASKS = `# Phase 50: Empty Phase - Plan
99
+
100
+ ## Overview
101
+
102
+ Nothing here yet.
103
+
104
+ ## Tasks
105
+
106
+ No tasks defined.
107
+ `;
108
+
109
+ const PLAN_SINGLE_TASK = `# Phase 10: Tiny Phase - Plan
110
+
111
+ ## Tasks
112
+
113
+ ### Task 1: Only Task [ ]
114
+
115
+ **Goal:** The only task.
116
+ `;
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // parsePlanTasks
120
+ // ---------------------------------------------------------------------------
121
+
122
+ describe('parsePlanTasks', () => {
123
+ it('extracts phase title correctly', () => {
124
+ const result = parsePlanTasks(SAMPLE_PLAN);
125
+
126
+ expect(result.phaseNumber).toBe(97);
127
+ expect(result.phaseTitle).toBe('GitHub Deep Integration');
128
+ });
129
+
130
+ it('extracts 5 tasks with correct numbers and titles', () => {
131
+ const result = parsePlanTasks(SAMPLE_PLAN);
132
+
133
+ expect(result.tasks).toHaveLength(5);
134
+ expect(result.tasks[0].number).toBe(1);
135
+ expect(result.tasks[0].title).toBe('GitHub Client — Issues + PRs');
136
+ expect(result.tasks[1].number).toBe(2);
137
+ expect(result.tasks[1].title).toBe('GitHub Client — Projects V2');
138
+ expect(result.tasks[2].number).toBe(3);
139
+ expect(result.tasks[2].title).toBe('Plan-to-Issues Sync');
140
+ expect(result.tasks[3].number).toBe(4);
141
+ expect(result.tasks[3].title).toBe('Hook Integration');
142
+ expect(result.tasks[4].number).toBe(5);
143
+ expect(result.tasks[4].title).toBe('Config + Setup');
144
+ });
145
+
146
+ it('detects todo/in_progress/done status markers', () => {
147
+ const result = parsePlanTasks(SAMPLE_PLAN);
148
+
149
+ expect(result.tasks[0].status).toBe('todo'); // [ ]
150
+ expect(result.tasks[1].status).toBe('in_progress'); // [>@alice]
151
+ expect(result.tasks[2].status).toBe('todo'); // [ ]
152
+ expect(result.tasks[3].status).toBe('done'); // [x]
153
+ expect(result.tasks[4].status).toBe('done'); // [x@bob]
154
+ });
155
+
156
+ it('extracts existing issue markers', () => {
157
+ const result = parsePlanTasks(PLAN_WITH_MARKERS);
158
+
159
+ expect(result.tasks[0].issueNumber).toBe(101);
160
+ expect(result.tasks[1].issueNumber).toBe(102);
161
+ expect(result.tasks[2].issueNumber).toBeNull();
162
+ });
163
+
164
+ it('handles plan with no tasks gracefully', () => {
165
+ const result = parsePlanTasks(PLAN_NO_TASKS);
166
+
167
+ expect(result.phaseNumber).toBe(50);
168
+ expect(result.phaseTitle).toBe('Empty Phase');
169
+ expect(result.tasks).toEqual([]);
170
+ });
171
+
172
+ it('extracts assignee from [>@alice]', () => {
173
+ const result = parsePlanTasks(SAMPLE_PLAN);
174
+
175
+ expect(result.tasks[1].assignee).toBe('alice');
176
+ expect(result.tasks[0].assignee).toBeNull();
177
+ expect(result.tasks[3].assignee).toBeNull(); // [x] has no assignee
178
+ });
179
+
180
+ it('extracts assignee from [x@bob]', () => {
181
+ const result = parsePlanTasks(SAMPLE_PLAN);
182
+
183
+ expect(result.tasks[4].assignee).toBe('bob');
184
+ });
185
+
186
+ it('handles single task plan', () => {
187
+ const result = parsePlanTasks(PLAN_SINGLE_TASK);
188
+
189
+ expect(result.phaseNumber).toBe(10);
190
+ expect(result.tasks).toHaveLength(1);
191
+ expect(result.tasks[0].number).toBe(1);
192
+ expect(result.tasks[0].title).toBe('Only Task');
193
+ expect(result.tasks[0].status).toBe('todo');
194
+ });
195
+ });
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // injectIssueMarkers
199
+ // ---------------------------------------------------------------------------
200
+
201
+ describe('injectIssueMarkers', () => {
202
+ it('adds markers after task headings', () => {
203
+ const taskIssueMap = { 1: 201, 3: 203 };
204
+ const result = injectIssueMarkers(SAMPLE_PLAN, taskIssueMap);
205
+
206
+ expect(result).toContain('### Task 1: GitHub Client — Issues + PRs [ ] <!-- #201 -->');
207
+ expect(result).toContain('### Task 3: Plan-to-Issues Sync [ ] <!-- #203 -->');
208
+ // Tasks without mapping should be unchanged
209
+ expect(result).toContain('### Task 2: GitHub Client — Projects V2 [>@alice]');
210
+ expect(result).not.toContain('### Task 2: GitHub Client — Projects V2 [>@alice] <!-- #');
211
+ });
212
+
213
+ it('is idempotent (does not duplicate existing markers)', () => {
214
+ const taskIssueMap = { 1: 101, 2: 102, 3: 300 };
215
+ const result = injectIssueMarkers(PLAN_WITH_MARKERS, taskIssueMap);
216
+
217
+ // Count occurrences of <!-- #101 -->
218
+ const matches101 = result.match(/<!-- #101 -->/g);
219
+ expect(matches101).toHaveLength(1);
220
+
221
+ // Count occurrences of <!-- #102 -->
222
+ const matches102 = result.match(/<!-- #102 -->/g);
223
+ expect(matches102).toHaveLength(1);
224
+
225
+ // Task 3 should now have the new marker
226
+ expect(result).toContain('### Task 3: Third Task [ ] <!-- #300 -->');
227
+ });
228
+
229
+ it('preserves existing markers when no new map entries', () => {
230
+ const taskIssueMap = {};
231
+ const result = injectIssueMarkers(PLAN_WITH_MARKERS, taskIssueMap);
232
+
233
+ expect(result).toContain('<!-- #101 -->');
234
+ expect(result).toContain('<!-- #102 -->');
235
+ expect(result).toBe(PLAN_WITH_MARKERS);
236
+ });
237
+
238
+ it('handles empty taskIssueMap', () => {
239
+ const result = injectIssueMarkers(SAMPLE_PLAN, {});
240
+
241
+ expect(result).toBe(SAMPLE_PLAN);
242
+ });
243
+ });
244
+
245
+ // ---------------------------------------------------------------------------
246
+ // Mock factories for syncPlan / updateTaskStatuses
247
+ // ---------------------------------------------------------------------------
248
+
249
+ function makeConfig({
250
+ org = 'KashaTech',
251
+ repo = 'my-project',
252
+ owner = 'KashaTech',
253
+ project = 'CashierLogic',
254
+ sprintField = 'Sprint',
255
+ statusField = 'Status',
256
+ phasePrefix = 'S',
257
+ } = {}) {
258
+ return {
259
+ github: {
260
+ autoSync: true,
261
+ project,
262
+ org,
263
+ sprintField,
264
+ statusField,
265
+ phasePrefix,
266
+ },
267
+ owner,
268
+ repo,
269
+ };
270
+ }
271
+
272
+ function makeGhClient() {
273
+ return {
274
+ createIssue: vi.fn().mockReturnValue({ number: 200, url: 'https://github.com/o/r/issues/200', id: 'I_200' }),
275
+ closeIssue: vi.fn().mockReturnValue({ closed: true }),
276
+ listIssues: vi.fn().mockReturnValue([]),
277
+ assignIssue: vi.fn().mockReturnValue({ assigned: true }),
278
+ addLabels: vi.fn().mockReturnValue({ labeled: true }),
279
+ };
280
+ }
281
+
282
+ function makeGhProjects() {
283
+ return {
284
+ getProjectInfo: vi.fn().mockReturnValue({
285
+ projectId: 'PVT_abc123',
286
+ title: 'CashierLogic',
287
+ number: 1,
288
+ fields: [
289
+ { id: 'PVTF_title', name: 'Title', type: 'field' },
290
+ {
291
+ id: 'PVTSSF_status',
292
+ name: 'Status',
293
+ type: 'single_select',
294
+ options: [
295
+ { id: 'opt_backlog', name: 'Backlog' },
296
+ { id: 'opt_inprogress', name: 'In progress' },
297
+ { id: 'opt_done', name: 'Done' },
298
+ ],
299
+ },
300
+ {
301
+ id: 'PVTSSF_sprint',
302
+ name: 'Sprint',
303
+ type: 'single_select',
304
+ options: [
305
+ { id: 'opt_s96', name: 'S96' },
306
+ ],
307
+ },
308
+ ],
309
+ }),
310
+ addItem: vi.fn().mockReturnValue({ itemId: 'PVTI_item1' }),
311
+ setField: vi.fn().mockReturnValue({ success: true }),
312
+ createOption: vi.fn().mockReturnValue({ optionId: 'opt_s97' }),
313
+ findField: vi.fn().mockImplementation((name) => {
314
+ if (name === 'Status') {
315
+ return {
316
+ id: 'PVTSSF_status',
317
+ name: 'Status',
318
+ type: 'single_select',
319
+ options: [
320
+ { id: 'opt_backlog', name: 'Backlog' },
321
+ { id: 'opt_inprogress', name: 'In progress' },
322
+ { id: 'opt_done', name: 'Done' },
323
+ ],
324
+ };
325
+ }
326
+ if (name === 'Sprint') {
327
+ return {
328
+ id: 'PVTSSF_sprint',
329
+ name: 'Sprint',
330
+ type: 'single_select',
331
+ options: [
332
+ { id: 'opt_s96', name: 'S96' },
333
+ ],
334
+ };
335
+ }
336
+ return null;
337
+ }),
338
+ findOption: vi.fn().mockImplementation((field, optionName) => {
339
+ if (!field || !field.options) return null;
340
+ return field.options.find(o => o.name.toLowerCase() === optionName.toLowerCase()) || null;
341
+ }),
342
+ getItems: vi.fn().mockReturnValue([]),
343
+ };
344
+ }
345
+
346
+ function makeMockFs(planContent = SAMPLE_PLAN) {
347
+ return {
348
+ readFileSync: vi.fn().mockReturnValue(planContent),
349
+ writeFileSync: vi.fn(),
350
+ };
351
+ }
352
+
353
+ // ---------------------------------------------------------------------------
354
+ // syncPlan
355
+ // ---------------------------------------------------------------------------
356
+
357
+ describe('syncPlan', () => {
358
+ let ghClient, ghProjects, config, mockFs;
359
+
360
+ beforeEach(() => {
361
+ ghClient = makeGhClient();
362
+ ghProjects = makeGhProjects();
363
+ config = makeConfig();
364
+ mockFs = makeMockFs();
365
+ });
366
+
367
+ it('creates parent phase issue', () => {
368
+ // listIssues returns empty (no existing phase issue)
369
+ ghClient.listIssues.mockReturnValue([]);
370
+
371
+ syncPlan({
372
+ planPath: '/project/.planning/phases/97-PLAN.md',
373
+ config,
374
+ ghClient,
375
+ ghProjects,
376
+ fs: mockFs,
377
+ });
378
+
379
+ // Should create parent issue with phase title
380
+ expect(ghClient.createIssue).toHaveBeenCalledWith(
381
+ expect.objectContaining({
382
+ title: expect.stringContaining('Phase 97'),
383
+ })
384
+ );
385
+ });
386
+
387
+ it('creates sub-issues for each task', () => {
388
+ ghClient.listIssues.mockReturnValue([]);
389
+ // Return different issue numbers for each creation
390
+ let issueCounter = 300;
391
+ ghClient.createIssue.mockImplementation(() => {
392
+ const num = issueCounter++;
393
+ return { number: num, url: `https://github.com/o/r/issues/${num}`, id: `I_${num}` };
394
+ });
395
+
396
+ const result = syncPlan({
397
+ planPath: '/project/.planning/phases/97-PLAN.md',
398
+ config,
399
+ ghClient,
400
+ ghProjects,
401
+ fs: mockFs,
402
+ });
403
+
404
+ // 1 parent + 5 tasks = 6 createIssue calls
405
+ expect(ghClient.createIssue).toHaveBeenCalledTimes(6);
406
+
407
+ // Verify task issue titles contain [Phase 97]
408
+ const calls = ghClient.createIssue.mock.calls;
409
+ // Second call onward should be tasks (first is parent)
410
+ expect(calls[1][0].title).toContain('[Phase 97] Task 1:');
411
+ expect(calls[2][0].title).toContain('[Phase 97] Task 2:');
412
+
413
+ expect(result.created).toBeGreaterThan(0);
414
+ });
415
+
416
+ it('skips tasks that already have issue markers', () => {
417
+ mockFs = makeMockFs(PLAN_WITH_MARKERS);
418
+ ghClient.listIssues.mockReturnValue([]);
419
+
420
+ let issueCounter = 500;
421
+ ghClient.createIssue.mockImplementation(() => {
422
+ const num = issueCounter++;
423
+ return { number: num, url: `https://github.com/o/r/issues/${num}`, id: `I_${num}` };
424
+ });
425
+
426
+ const result = syncPlan({
427
+ planPath: '/project/.planning/phases/42-PLAN.md',
428
+ config,
429
+ ghClient,
430
+ ghProjects,
431
+ fs: mockFs,
432
+ });
433
+
434
+ // Tasks 1 and 2 already have markers, only Task 3 + parent should be created
435
+ // Parent + 1 new task = 2 createIssue calls
436
+ expect(result.skipped).toBeGreaterThanOrEqual(2);
437
+ expect(result.created).toBe(1); // only Task 3
438
+ });
439
+
440
+ it('adds issues to project board with correct sprint', () => {
441
+ ghClient.listIssues.mockReturnValue([]);
442
+ ghClient.createIssue.mockReturnValue({ number: 400, url: 'https://github.com/o/r/issues/400', id: 'I_400' });
443
+
444
+ syncPlan({
445
+ planPath: '/project/.planning/phases/97-PLAN.md',
446
+ config,
447
+ ghClient,
448
+ ghProjects,
449
+ fs: mockFs,
450
+ });
451
+
452
+ // Should add items to project board
453
+ expect(ghProjects.addItem).toHaveBeenCalled();
454
+
455
+ // Should set Sprint field
456
+ expect(ghProjects.setField).toHaveBeenCalledWith(
457
+ expect.objectContaining({
458
+ fieldId: 'PVTSSF_sprint',
459
+ })
460
+ );
461
+ });
462
+
463
+ it('auto-creates sprint option if missing', () => {
464
+ ghClient.listIssues.mockReturnValue([]);
465
+ ghClient.createIssue.mockReturnValue({ number: 400, url: 'https://github.com/o/r/issues/400', id: 'I_400' });
466
+
467
+ // Sprint "S97" does not exist in current options (only S96)
468
+ // findOption for sprint should return null for S97
469
+ ghProjects.findOption.mockImplementation((field, optionName) => {
470
+ if (!field || !field.options) return null;
471
+ return field.options.find(o => o.name.toLowerCase() === optionName.toLowerCase()) || null;
472
+ });
473
+
474
+ syncPlan({
475
+ planPath: '/project/.planning/phases/97-PLAN.md',
476
+ config,
477
+ ghClient,
478
+ ghProjects,
479
+ fs: mockFs,
480
+ });
481
+
482
+ // Should have created sprint option "S97"
483
+ expect(ghProjects.createOption).toHaveBeenCalledWith(
484
+ expect.objectContaining({
485
+ fieldId: 'PVTSSF_sprint',
486
+ name: 'S97',
487
+ })
488
+ );
489
+ });
490
+
491
+ it('sets Status field based on task marker', () => {
492
+ ghClient.listIssues.mockReturnValue([]);
493
+ let issueCounter = 700;
494
+ ghClient.createIssue.mockImplementation(() => {
495
+ const num = issueCounter++;
496
+ return { number: num, url: `https://github.com/o/r/issues/${num}`, id: `I_${num}` };
497
+ });
498
+
499
+ syncPlan({
500
+ planPath: '/project/.planning/phases/97-PLAN.md',
501
+ config,
502
+ ghClient,
503
+ ghProjects,
504
+ fs: mockFs,
505
+ });
506
+
507
+ // Should set Status for each task
508
+ // Task 1 [ ] → Backlog, Task 2 [>@alice] → In progress, Task 4 [x] → Done, etc.
509
+ const setFieldCalls = ghProjects.setField.mock.calls;
510
+ const statusCalls = setFieldCalls.filter(c => c[0].fieldId === 'PVTSSF_status');
511
+
512
+ // There should be status calls for each task
513
+ expect(statusCalls.length).toBeGreaterThan(0);
514
+
515
+ // Check that different option IDs are used for different statuses
516
+ const optionIds = statusCalls.map(c => c[0].optionId);
517
+ // Should see both backlog and in-progress and done
518
+ expect(optionIds).toContain('opt_backlog');
519
+ expect(optionIds).toContain('opt_inprogress');
520
+ expect(optionIds).toContain('opt_done');
521
+ });
522
+
523
+ it('writes markers back to PLAN.md', () => {
524
+ ghClient.listIssues.mockReturnValue([]);
525
+ let issueCounter = 800;
526
+ ghClient.createIssue.mockImplementation(() => {
527
+ const num = issueCounter++;
528
+ return { number: num, url: `https://github.com/o/r/issues/${num}`, id: `I_${num}` };
529
+ });
530
+
531
+ syncPlan({
532
+ planPath: '/project/.planning/phases/97-PLAN.md',
533
+ config,
534
+ ghClient,
535
+ ghProjects,
536
+ fs: mockFs,
537
+ });
538
+
539
+ // Should write back to the plan file
540
+ expect(mockFs.writeFileSync).toHaveBeenCalledWith(
541
+ '/project/.planning/phases/97-PLAN.md',
542
+ expect.stringContaining('<!-- #')
543
+ );
544
+ });
545
+
546
+ it('returns correct created/updated/skipped counts', () => {
547
+ ghClient.listIssues.mockReturnValue([]);
548
+ let issueCounter = 900;
549
+ ghClient.createIssue.mockImplementation(() => {
550
+ const num = issueCounter++;
551
+ return { number: num, url: `https://github.com/o/r/issues/${num}`, id: `I_${num}` };
552
+ });
553
+
554
+ const result = syncPlan({
555
+ planPath: '/project/.planning/phases/97-PLAN.md',
556
+ config,
557
+ ghClient,
558
+ ghProjects,
559
+ fs: mockFs,
560
+ });
561
+
562
+ expect(result).toHaveProperty('created');
563
+ expect(result).toHaveProperty('updated');
564
+ expect(result).toHaveProperty('skipped');
565
+ expect(result).toHaveProperty('errors');
566
+ expect(result.created).toBe(5);
567
+ expect(result.skipped).toBe(0);
568
+ expect(Array.isArray(result.errors)).toBe(true);
569
+ });
570
+
571
+ it('handles ghClient errors gracefully (logs, continues with next task)', () => {
572
+ ghClient.listIssues.mockReturnValue([]);
573
+ let callCount = 0;
574
+ ghClient.createIssue.mockImplementation(() => {
575
+ callCount++;
576
+ // First call (parent) succeeds, second call (task 1) fails, rest succeed
577
+ if (callCount === 2) {
578
+ return { error: 'API rate limit exceeded', code: 'GH_API_ERROR' };
579
+ }
580
+ return { number: callCount * 100, url: `https://github.com/o/r/issues/${callCount * 100}`, node_id: `I_${callCount * 100}` };
581
+ });
582
+
583
+ const result = syncPlan({
584
+ planPath: '/project/.planning/phases/97-PLAN.md',
585
+ config,
586
+ ghClient,
587
+ ghProjects,
588
+ fs: mockFs,
589
+ });
590
+
591
+ // Should continue creating other issues despite the failure
592
+ expect(ghClient.createIssue.mock.calls.length).toBeGreaterThan(2);
593
+ // Should report the error
594
+ expect(result.errors.length).toBeGreaterThan(0);
595
+ });
596
+
597
+ it('uses phasePrefix "S" to build sprint name "S97"', () => {
598
+ ghClient.listIssues.mockReturnValue([]);
599
+ ghClient.createIssue.mockReturnValue({ number: 400, url: 'https://github.com/o/r/issues/400', id: 'I_400' });
600
+ config.github.phasePrefix = 'S';
601
+
602
+ syncPlan({
603
+ planPath: '/project/.planning/phases/97-PLAN.md',
604
+ config,
605
+ ghClient,
606
+ ghProjects,
607
+ fs: mockFs,
608
+ });
609
+
610
+ // Check that createOption was called with "S97" (not "Phase-97")
611
+ if (ghProjects.createOption.mock.calls.length > 0) {
612
+ expect(ghProjects.createOption).toHaveBeenCalledWith(
613
+ expect.objectContaining({ name: 'S97' })
614
+ );
615
+ }
616
+ });
617
+
618
+ it('uses phasePrefix "Phase" to build sprint name "Phase-97"', () => {
619
+ ghClient.listIssues.mockReturnValue([]);
620
+ ghClient.createIssue.mockReturnValue({ number: 400, url: 'https://github.com/o/r/issues/400', id: 'I_400' });
621
+ config.github.phasePrefix = 'Phase';
622
+
623
+ syncPlan({
624
+ planPath: '/project/.planning/phases/97-PLAN.md',
625
+ config,
626
+ ghClient,
627
+ ghProjects,
628
+ fs: mockFs,
629
+ });
630
+
631
+ if (ghProjects.createOption.mock.calls.length > 0) {
632
+ expect(ghProjects.createOption).toHaveBeenCalledWith(
633
+ expect.objectContaining({ name: 'Phase-97' })
634
+ );
635
+ }
636
+ });
637
+
638
+ it('does not re-create parent phase issue if it already exists', () => {
639
+ // listIssues returns an existing phase issue
640
+ ghClient.listIssues.mockReturnValue([
641
+ { number: 50, title: 'Phase 97: GitHub Deep Integration', state: 'OPEN', labels: [], assignees: [] },
642
+ ]);
643
+
644
+ let issueCounter = 600;
645
+ ghClient.createIssue.mockImplementation(() => {
646
+ const num = issueCounter++;
647
+ return { number: num, url: `https://github.com/o/r/issues/${num}`, id: `I_${num}` };
648
+ });
649
+
650
+ syncPlan({
651
+ planPath: '/project/.planning/phases/97-PLAN.md',
652
+ config,
653
+ ghClient,
654
+ ghProjects,
655
+ fs: mockFs,
656
+ });
657
+
658
+ // No call should create a parent issue with "Phase 97:" prefix as title
659
+ // Only task issues should be created (5 tasks, not 6)
660
+ expect(ghClient.createIssue).toHaveBeenCalledTimes(5);
661
+ });
662
+ });
663
+
664
+ // ---------------------------------------------------------------------------
665
+ // updateTaskStatuses
666
+ // ---------------------------------------------------------------------------
667
+
668
+ describe('updateTaskStatuses', () => {
669
+ let ghClient, ghProjects, config, mockFs;
670
+
671
+ beforeEach(() => {
672
+ ghClient = makeGhClient();
673
+ ghProjects = makeGhProjects();
674
+ config = makeConfig();
675
+ // Plan with markers: Task 1 todo #101, Task 2 in_progress #102, Task 3 todo (no marker)
676
+ mockFs = makeMockFs(PLAN_WITH_MARKERS);
677
+ });
678
+
679
+ it('closes issues for done tasks', () => {
680
+ // Change Task 1 to done in the plan content
681
+ const donePlan = PLAN_WITH_MARKERS.replace(
682
+ '### Task 1: First Task [ ] <!-- #101 -->',
683
+ '### Task 1: First Task [x] <!-- #101 -->'
684
+ );
685
+ mockFs = makeMockFs(donePlan);
686
+
687
+ // Mock getItems to return current project state (Task 1 was "Backlog")
688
+ ghProjects.getItems.mockReturnValue([
689
+ {
690
+ itemId: 'PVTI_1',
691
+ contentId: 'I_101',
692
+ contentType: 'Issue',
693
+ title: '[Phase 42] Task 1: First Task',
694
+ fieldValues: { Status: 'Backlog', Sprint: 'S42' },
695
+ },
696
+ ]);
697
+
698
+ const result = updateTaskStatuses({
699
+ planPath: '/project/.planning/phases/42-PLAN.md',
700
+ config,
701
+ ghClient,
702
+ ghProjects,
703
+ fs: mockFs,
704
+ });
705
+
706
+ // Should close the issue
707
+ expect(ghClient.closeIssue).toHaveBeenCalledWith(
708
+ expect.objectContaining({ number: 101 })
709
+ );
710
+ expect(result.closed).toBeGreaterThanOrEqual(1);
711
+ });
712
+
713
+ it('assigns issues for claimed tasks', () => {
714
+ // Task 2 is [>@carol] with marker #102
715
+ ghProjects.getItems.mockReturnValue([
716
+ {
717
+ itemId: 'PVTI_2',
718
+ contentId: 'I_102',
719
+ contentType: 'Issue',
720
+ title: '[Phase 42] Task 2: Second Task',
721
+ fieldValues: { Status: 'Backlog', Sprint: 'S42' },
722
+ },
723
+ ]);
724
+
725
+ const result = updateTaskStatuses({
726
+ planPath: '/project/.planning/phases/42-PLAN.md',
727
+ config,
728
+ ghClient,
729
+ ghProjects,
730
+ fs: mockFs,
731
+ });
732
+
733
+ // Should assign carol to issue #102
734
+ expect(ghClient.assignIssue).toHaveBeenCalledWith(
735
+ expect.objectContaining({
736
+ number: 102,
737
+ assignees: ['carol'],
738
+ })
739
+ );
740
+ expect(result.updated).toBeGreaterThanOrEqual(1);
741
+ });
742
+
743
+ it('sets project Status field correctly', () => {
744
+ // Task 2 is in_progress — should set Status to "In progress"
745
+ ghProjects.getItems.mockReturnValue([
746
+ {
747
+ itemId: 'PVTI_2',
748
+ contentId: 'I_102',
749
+ contentType: 'Issue',
750
+ title: '[Phase 42] Task 2: Second Task',
751
+ fieldValues: { Status: 'Backlog', Sprint: 'S42' },
752
+ },
753
+ ]);
754
+
755
+ const result = updateTaskStatuses({
756
+ planPath: '/project/.planning/phases/42-PLAN.md',
757
+ config,
758
+ ghClient,
759
+ ghProjects,
760
+ fs: mockFs,
761
+ });
762
+
763
+ // Should set Status to "In progress"
764
+ expect(ghProjects.setField).toHaveBeenCalledWith(
765
+ expect.objectContaining({
766
+ fieldId: 'PVTSSF_status',
767
+ optionId: 'opt_inprogress',
768
+ })
769
+ );
770
+ expect(result.updated).toBeGreaterThanOrEqual(1);
771
+ });
772
+
773
+ it('skips tasks without issue markers', () => {
774
+ // Task 3 has no marker — should not attempt any updates for it
775
+ ghProjects.getItems.mockReturnValue([]);
776
+
777
+ const result = updateTaskStatuses({
778
+ planPath: '/project/.planning/phases/42-PLAN.md',
779
+ config,
780
+ ghClient,
781
+ ghProjects,
782
+ fs: mockFs,
783
+ });
784
+
785
+ // Task 3 has no marker, so it should not trigger closeIssue or assignIssue for it
786
+ // Only Task 1 and Task 2 have markers
787
+ expect(result).toHaveProperty('updated');
788
+ expect(result).toHaveProperty('closed');
789
+ });
790
+
791
+ it('returns correct counts', () => {
792
+ ghProjects.getItems.mockReturnValue([]);
793
+
794
+ const result = updateTaskStatuses({
795
+ planPath: '/project/.planning/phases/42-PLAN.md',
796
+ config,
797
+ ghClient,
798
+ ghProjects,
799
+ fs: mockFs,
800
+ });
801
+
802
+ expect(typeof result.updated).toBe('number');
803
+ expect(typeof result.closed).toBe('number');
804
+ });
805
+ });