tlc-claude-code 2.4.10 → 2.6.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.
- package/.claude/commands/tlc/autofix.md +34 -1
- package/.claude/commands/tlc/build.md +203 -27
- package/.claude/commands/tlc/ci.md +178 -414
- package/.claude/commands/tlc/coverage.md +34 -0
- package/.claude/commands/tlc/deploy.md +19 -6
- package/.claude/commands/tlc/discuss.md +34 -0
- package/.claude/commands/tlc/docs.md +35 -1
- package/.claude/commands/tlc/e2e.md +300 -0
- package/.claude/commands/tlc/edge-cases.md +35 -1
- package/.claude/commands/tlc/init.md +38 -8
- package/.claude/commands/tlc/issues.md +46 -0
- package/.claude/commands/tlc/new-project.md +46 -4
- package/.claude/commands/tlc/plan.md +76 -0
- package/.claude/commands/tlc/quick.md +33 -0
- package/.claude/commands/tlc/release.md +85 -135
- package/.claude/commands/tlc/restore.md +14 -0
- package/.claude/commands/tlc/review.md +80 -1
- package/.claude/commands/tlc/tlc.md +134 -0
- package/.claude/commands/tlc/verify.md +64 -65
- package/.claude/commands/tlc/watchci.md +10 -0
- package/.claude/hooks/tlc-block-tools.sh +13 -0
- package/.claude/hooks/tlc-session-init.sh +9 -0
- package/CODING-STANDARDS.md +35 -10
- package/package.json +1 -1
- package/server/lib/block-tools-hook.js +23 -0
- package/server/lib/e2e/acceptance-parser.js +132 -0
- package/server/lib/e2e/acceptance-parser.test.js +110 -0
- package/server/lib/e2e/framework-detector.js +47 -0
- package/server/lib/e2e/framework-detector.test.js +94 -0
- package/server/lib/e2e/log-assertions.js +107 -0
- package/server/lib/e2e/log-assertions.test.js +68 -0
- package/server/lib/e2e/test-generator.js +159 -0
- package/server/lib/e2e/test-generator.test.js +121 -0
- package/server/lib/e2e/verify-runner.js +191 -0
- package/server/lib/e2e/verify-runner.test.js +167 -0
- package/server/lib/github/config.js +458 -0
- package/server/lib/github/config.test.js +385 -0
- package/server/lib/github/gh-client.js +303 -0
- package/server/lib/github/gh-client.test.js +499 -0
- package/server/lib/github/gh-projects.js +594 -0
- package/server/lib/github/gh-projects.test.js +583 -0
- package/server/lib/github/index.js +19 -0
- package/server/lib/github/plan-sync.js +456 -0
- package/server/lib/github/plan-sync.test.js +805 -0
- package/server/lib/hooks/block-tools-hook.test.js +54 -0
- package/server/lib/orchestration/cli-dispatch.js +16 -1
- package/server/lib/orchestration/cli-dispatch.test.js +94 -8
- package/server/lib/orchestration/completion-checker.js +101 -0
- package/server/lib/orchestration/completion-checker.test.js +177 -0
- package/server/lib/orchestration/result-verifier.js +143 -0
- package/server/lib/orchestration/result-verifier.test.js +291 -0
- package/server/lib/orchestration/session-dispatcher.js +99 -0
- package/server/lib/orchestration/session-dispatcher.test.js +215 -0
- package/server/lib/orchestration/session-status.js +147 -0
- package/server/lib/orchestration/session-status.test.js +130 -0
- package/server/lib/release/agent-runner-updates.js +24 -0
- package/server/lib/release/agent-runner-updates.test.js +22 -0
- package/server/lib/release/changelog-generator.js +142 -0
- package/server/lib/release/changelog-generator.test.js +113 -0
- package/server/lib/release/ci-watcher.js +83 -0
- package/server/lib/release/ci-watcher.test.js +81 -0
- package/server/lib/release/health-checker.js +111 -0
- package/server/lib/release/health-checker.test.js +121 -0
- package/server/lib/release/release-pipeline.js +187 -0
- package/server/lib/release/release-pipeline.test.js +262 -0
- package/server/lib/release/version-bumper.js +183 -0
- package/server/lib/release/version-bumper.test.js +142 -0
- package/server/lib/routing-preamble.integration.test.js +12 -0
- package/server/lib/routing-preamble.js +13 -2
- package/server/lib/routing-preamble.test.js +49 -0
- package/server/lib/scaffolding/ci-detector.js +139 -0
- package/server/lib/scaffolding/ci-detector.test.js +198 -0
- package/server/lib/scaffolding/ci-scaffolder.js +347 -0
- package/server/lib/scaffolding/ci-scaffolder.test.js +157 -0
- package/server/lib/scaffolding/deploy-detector.js +135 -0
- package/server/lib/scaffolding/deploy-detector.test.js +106 -0
- package/server/lib/scaffolding/health-scaffold.js +374 -0
- package/server/lib/scaffolding/health-scaffold.test.js +99 -0
- package/server/lib/scaffolding/logger-scaffold.js +196 -0
- package/server/lib/scaffolding/logger-scaffold.test.js +146 -0
- package/server/lib/scaffolding/migration-detector.js +78 -0
- package/server/lib/scaffolding/migration-detector.test.js +127 -0
- package/server/lib/scaffolding/snapshot-manager.js +142 -0
- package/server/lib/scaffolding/snapshot-manager.test.js +225 -0
- package/server/lib/task-router-config.js +50 -20
- package/server/lib/task-router-config.test.js +29 -15
|
@@ -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
|
+
});
|