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,456 @@
1
+ /**
2
+ * Plan-to-Issues Sync — Phase 97, Task 3
3
+ *
4
+ * Parses PLAN.md task headings, creates GitHub issues + project board items,
5
+ * writes issue markers back into the plan. Idempotent — safe to re-run.
6
+ *
7
+ * All external dependencies (ghClient, ghProjects, fs) are injected.
8
+ * No silent failures: every catch logs with context or rethrows.
9
+ *
10
+ * @module plan-sync
11
+ */
12
+
13
+ const nodeFs = require('fs');
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Status mapping
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /** Map plan marker → project board Status option name */
20
+ const STATUS_MAP = {
21
+ todo: 'Backlog',
22
+ in_progress: 'In progress',
23
+ done: 'Done',
24
+ };
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Sprint name helper
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /**
31
+ * Build the sprint name from config prefix + phase number.
32
+ * - prefix "S" + phase 97 → "S97"
33
+ * - prefix "Phase" + phase 97 → "Phase-97"
34
+ *
35
+ * @param {string} prefix - e.g. "S" or "Phase"
36
+ * @param {number} phaseNumber
37
+ * @returns {string}
38
+ */
39
+ function buildSprintName(prefix, phaseNumber) {
40
+ if (prefix.length <= 1) return `${prefix}${phaseNumber}`;
41
+ return `${prefix}-${phaseNumber}`;
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // parsePlanTasks
46
+ // ---------------------------------------------------------------------------
47
+
48
+ /**
49
+ * Parse a PLAN.md file and extract phase info + tasks.
50
+ *
51
+ * Recognises headings like:
52
+ * ### Task 1: Title [ ]
53
+ * ### Task 2: Title [>@user]
54
+ * ### Task 3: Title [x]
55
+ * ### Task 4: Title [x@user]
56
+ * ### Task 5: Title [ ] <!-- #123 -->
57
+ *
58
+ * @param {string} planContent - Raw PLAN.md content
59
+ * @returns {{ phaseNumber: number, phaseTitle: string, tasks: Array<{ number: number, title: string, status: string, assignee: string|null, issueNumber: number|null }> }}
60
+ */
61
+ function parsePlanTasks(planContent) {
62
+ // Extract phase heading: # Phase N: Title ...
63
+ const phaseMatch = planContent.match(/^#\s+Phase\s+(\d+):\s*(.+?)(?:\s*-\s*Plan)?\s*$/m);
64
+ const phaseNumber = phaseMatch ? parseInt(phaseMatch[1], 10) : 0;
65
+ const phaseTitle = phaseMatch ? phaseMatch[2].trim() : 'Unknown';
66
+
67
+ // Extract tasks from ### Task N: Title [marker] <!-- #issueNum -->
68
+ const taskRegex = /^###\s+Task\s+(\d+):\s*(.+?)\s+\[([^\]]*)\](?:\s*<!--\s*#(\d+)\s*-->)?/gm;
69
+ const tasks = [];
70
+
71
+ let match;
72
+ while ((match = taskRegex.exec(planContent)) !== null) {
73
+ const number = parseInt(match[1], 10);
74
+ const title = match[2].trim();
75
+ const marker = match[3].trim();
76
+ const issueNumber = match[4] ? parseInt(match[4], 10) : null;
77
+
78
+ let status = 'todo';
79
+ let assignee = null;
80
+
81
+ if (marker.startsWith('>@')) {
82
+ status = 'in_progress';
83
+ assignee = marker.slice(2);
84
+ } else if (marker.startsWith('x@')) {
85
+ status = 'done';
86
+ assignee = marker.slice(2);
87
+ } else if (marker === 'x') {
88
+ status = 'done';
89
+ }
90
+ // marker === '' or ' ' → todo (default)
91
+
92
+ tasks.push({ number, title, status, assignee, issueNumber });
93
+ }
94
+
95
+ return { phaseNumber, phaseTitle, tasks };
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // injectIssueMarkers
100
+ // ---------------------------------------------------------------------------
101
+
102
+ /**
103
+ * Inject `<!-- #N -->` markers after task headings. Idempotent.
104
+ *
105
+ * @param {string} planContent - Raw PLAN.md content
106
+ * @param {Object<number, number>} taskIssueMap - { taskNumber: issueNumber }
107
+ * @returns {string} Modified plan content
108
+ */
109
+ function injectIssueMarkers(planContent, taskIssueMap) {
110
+ if (!taskIssueMap || Object.keys(taskIssueMap).length === 0) return planContent;
111
+
112
+ let result = planContent;
113
+
114
+ for (const [taskNum, issueNum] of Object.entries(taskIssueMap)) {
115
+ // Match the task heading line — with or without an existing marker
116
+ const regex = new RegExp(
117
+ `(^###\\s+Task\\s+${taskNum}:\\s*.+?\\s+\\[[^\\]]*\\])(?:\\s*<!--\\s*#\\d+\\s*-->)?\\s*$`,
118
+ 'gm'
119
+ );
120
+ result = result.replace(regex, `$1 <!-- #${issueNum} -->`);
121
+ }
122
+
123
+ return result;
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // syncPlan
128
+ // ---------------------------------------------------------------------------
129
+
130
+ /**
131
+ * Sync a PLAN.md to GitHub issues and project board.
132
+ *
133
+ * 1. Read + parse plan
134
+ * 2. Create parent phase issue if not exists
135
+ * 3. Create sub-issues for tasks without markers
136
+ * 4. Add to project board, set Sprint + Status
137
+ * 5. Write markers back
138
+ *
139
+ * @param {Object} params
140
+ * @param {string} params.planPath - Absolute path to PLAN.md
141
+ * @param {Object} params.config - Config with github section + owner/repo
142
+ * @param {Object} params.ghClient - GitHub client (createIssue, closeIssue, listIssues, etc.)
143
+ * @param {Object} params.ghProjects - Projects client (addItem, setField, findField, etc.)
144
+ * @param {Object} [params.fs] - fs module (readFileSync, writeFileSync)
145
+ * @returns {{ created: number, updated: number, skipped: number, errors: string[] }}
146
+ */
147
+ function syncPlan({ planPath, config, ghClient, ghProjects, fs = nodeFs }) {
148
+ const result = { created: 0, updated: 0, skipped: 0, errors: [] };
149
+ const { owner, repo } = config;
150
+ const ghConfig = config.github;
151
+
152
+ // 1. Read + parse
153
+ let planContent;
154
+ try {
155
+ planContent = fs.readFileSync(planPath, 'utf-8');
156
+ } catch (err) {
157
+ const msg = `[plan-sync] Failed to read plan at ${planPath}: ${err.message}`;
158
+ console.error(msg);
159
+ result.errors.push(msg);
160
+ return result;
161
+ }
162
+
163
+ const plan = parsePlanTasks(planContent);
164
+ if (plan.tasks.length === 0) {
165
+ console.log(`[plan-sync] No tasks found in ${planPath}, nothing to sync`);
166
+ return result;
167
+ }
168
+
169
+ // 2. Find or create parent phase issue
170
+ let parentIssueNumber = null;
171
+ try {
172
+ const existingIssues = ghClient.listIssues({
173
+ owner,
174
+ repo,
175
+ labels: [`tlc:phase-${plan.phaseNumber}`],
176
+ state: 'all',
177
+ });
178
+
179
+ if (Array.isArray(existingIssues)) {
180
+ const existing = existingIssues.find(i =>
181
+ i.title && i.title.startsWith(`Phase ${plan.phaseNumber}:`)
182
+ );
183
+ if (existing) {
184
+ parentIssueNumber = existing.number;
185
+ }
186
+ } else {
187
+ console.warn(`[plan-sync] listIssues returned non-array: ${existingIssues?.error || 'unknown'}`);
188
+ }
189
+ } catch (err) {
190
+ console.error(`[plan-sync] Failed to check for existing phase issue: ${err.message}`);
191
+ }
192
+
193
+ if (!parentIssueNumber) {
194
+ const parentResult = ghClient.createIssue({
195
+ owner,
196
+ repo,
197
+ title: `Phase ${plan.phaseNumber}: ${plan.phaseTitle}`,
198
+ body: `Phase ${plan.phaseNumber} tracking issue.\n\nTasks: ${plan.tasks.length}`,
199
+ labels: [`tlc:phase-${plan.phaseNumber}`],
200
+ });
201
+
202
+ if (parentResult.error) {
203
+ const msg = `[plan-sync] Failed to create parent phase issue: ${parentResult.error}`;
204
+ console.error(msg);
205
+ result.errors.push(msg);
206
+ } else {
207
+ parentIssueNumber = parentResult.number;
208
+ }
209
+ }
210
+
211
+ // 3. Resolve Sprint field + option
212
+ const sprintField = ghProjects.findField(ghConfig.sprintField || 'Sprint');
213
+ const statusField = ghProjects.findField(ghConfig.statusField || 'Status');
214
+ const sprintName = buildSprintName(ghConfig.phasePrefix || 'Phase', plan.phaseNumber);
215
+
216
+ let sprintOptionId = null;
217
+ if (sprintField) {
218
+ const existingOption = ghProjects.findOption(sprintField, sprintName);
219
+ if (existingOption) {
220
+ sprintOptionId = existingOption.id;
221
+ } else {
222
+ // Auto-create sprint option
223
+ const createResult = ghProjects.createOption({
224
+ fieldId: sprintField.id,
225
+ name: sprintName,
226
+ });
227
+ if (createResult.error) {
228
+ const msg = `[plan-sync] Failed to create sprint option "${sprintName}": ${createResult.error}`;
229
+ console.error(msg);
230
+ result.errors.push(msg);
231
+ } else {
232
+ sprintOptionId = createResult.optionId;
233
+ }
234
+ }
235
+ }
236
+
237
+ // 4. Create issues for tasks without markers
238
+ const taskIssueMap = {};
239
+
240
+ for (const task of plan.tasks) {
241
+ if (task.issueNumber) {
242
+ result.skipped++;
243
+ continue;
244
+ }
245
+
246
+ // Create issue for this task
247
+ const body = parentIssueNumber
248
+ ? `Part of #${parentIssueNumber}\n\n**[Phase ${plan.phaseNumber}] Task ${task.number}: ${task.title}**`
249
+ : `**[Phase ${plan.phaseNumber}] Task ${task.number}: ${task.title}**`;
250
+
251
+ const issueResult = ghClient.createIssue({
252
+ owner,
253
+ repo,
254
+ title: `[Phase ${plan.phaseNumber}] Task ${task.number}: ${task.title}`,
255
+ body,
256
+ labels: [`tlc:phase-${plan.phaseNumber}`, 'tlc:task'],
257
+ });
258
+
259
+ if (issueResult.error) {
260
+ const msg = `[plan-sync] Failed to create issue for Task ${task.number} "${task.title}": ${issueResult.error}`;
261
+ console.error(msg);
262
+ result.errors.push(msg);
263
+ continue;
264
+ }
265
+
266
+ taskIssueMap[task.number] = issueResult.number;
267
+ result.created++;
268
+
269
+ // Add to project board
270
+ const contentId = issueResult.id || `I_${issueResult.number}`;
271
+ const addResult = ghProjects.addItem({ contentId });
272
+
273
+ if (addResult.error) {
274
+ const msg = `[plan-sync] Failed to add issue #${issueResult.number} to project: ${addResult.error}`;
275
+ console.error(msg);
276
+ result.errors.push(msg);
277
+ continue;
278
+ }
279
+
280
+ const itemId = addResult.itemId;
281
+
282
+ // Set Sprint field
283
+ if (sprintField && sprintOptionId && itemId) {
284
+ const sprintResult = ghProjects.setField({
285
+ itemId,
286
+ fieldId: sprintField.id,
287
+ optionId: sprintOptionId,
288
+ });
289
+ if (sprintResult.error) {
290
+ const msg = `[plan-sync] Failed to set Sprint for issue #${issueResult.number}: ${sprintResult.error}`;
291
+ console.error(msg);
292
+ result.errors.push(msg);
293
+ }
294
+ }
295
+
296
+ // Set Status field
297
+ if (statusField && itemId) {
298
+ const statusName = STATUS_MAP[task.status] || 'Backlog';
299
+ const statusOption = ghProjects.findOption(statusField, statusName);
300
+ if (statusOption) {
301
+ const statusResult = ghProjects.setField({
302
+ itemId,
303
+ fieldId: statusField.id,
304
+ optionId: statusOption.id,
305
+ });
306
+ if (statusResult.error) {
307
+ const msg = `[plan-sync] Failed to set Status for issue #${issueResult.number}: ${statusResult.error}`;
308
+ console.error(msg);
309
+ result.errors.push(msg);
310
+ }
311
+ }
312
+ }
313
+
314
+ // Assign if claimed
315
+ if (task.assignee) {
316
+ const assignResult = ghClient.assignIssue({
317
+ owner,
318
+ repo,
319
+ number: issueResult.number,
320
+ assignees: [task.assignee],
321
+ });
322
+ if (assignResult && assignResult.error) {
323
+ const msg = `[plan-sync] Failed to assign ${task.assignee} to issue #${issueResult.number}: ${assignResult.error}`;
324
+ console.error(msg);
325
+ result.errors.push(msg);
326
+ }
327
+ }
328
+ }
329
+
330
+ // 5. Write markers back to PLAN.md
331
+ if (Object.keys(taskIssueMap).length > 0) {
332
+ try {
333
+ const updatedContent = injectIssueMarkers(planContent, taskIssueMap);
334
+ fs.writeFileSync(planPath, updatedContent);
335
+ } catch (err) {
336
+ const msg = `[plan-sync] Failed to write markers back to ${planPath}: ${err.message}`;
337
+ console.error(msg);
338
+ result.errors.push(msg);
339
+ }
340
+ }
341
+
342
+ return result;
343
+ }
344
+
345
+ // ---------------------------------------------------------------------------
346
+ // updateTaskStatuses
347
+ // ---------------------------------------------------------------------------
348
+
349
+ /**
350
+ * Lighter sync: only update statuses for tasks with existing issue markers.
351
+ * No issue creation. For use after /tlc:build marks tasks [x].
352
+ *
353
+ * @param {Object} params
354
+ * @param {string} params.planPath - Absolute path to PLAN.md
355
+ * @param {Object} params.config - Config with github section + owner/repo
356
+ * @param {Object} params.ghClient - GitHub client
357
+ * @param {Object} params.ghProjects - Projects client
358
+ * @param {Object} [params.fs] - fs module
359
+ * @returns {{ updated: number, closed: number }}
360
+ */
361
+ function updateTaskStatuses({ planPath, config, ghClient, ghProjects, fs = nodeFs }) {
362
+ const result = { updated: 0, closed: 0 };
363
+ const { owner, repo } = config;
364
+ const ghConfig = config.github;
365
+
366
+ // Read + parse
367
+ let planContent;
368
+ try {
369
+ planContent = fs.readFileSync(planPath, 'utf-8');
370
+ } catch (err) {
371
+ console.error(`[plan-sync] Failed to read plan at ${planPath}: ${err.message}`);
372
+ return { updated: 0, closed: 0, error: 'Failed to read plan file' };
373
+ }
374
+
375
+ const plan = parsePlanTasks(planContent);
376
+ const tasksWithMarkers = plan.tasks.filter(t => t.issueNumber !== null);
377
+
378
+ if (tasksWithMarkers.length === 0) {
379
+ console.log(`[plan-sync] No tasks with issue markers in ${planPath}, nothing to update`);
380
+ return result;
381
+ }
382
+
383
+ // Get project items for matching
384
+ const statusField = ghProjects.findField(ghConfig.statusField || 'Status');
385
+ const projectItems = ghProjects.getItems() || [];
386
+
387
+ for (const task of tasksWithMarkers) {
388
+ // Find the matching project item by issue number in title
389
+ const matchingItem = Array.isArray(projectItems)
390
+ ? projectItems.find(item =>
391
+ item.title && item.title.includes(`Task ${task.number}:`)
392
+ )
393
+ : null;
394
+
395
+ // Close issue if done
396
+ if (task.status === 'done') {
397
+ const closeResult = ghClient.closeIssue({ owner, repo, number: task.issueNumber });
398
+ if (closeResult.error) {
399
+ console.error(`[plan-sync] Failed to close #${task.issueNumber}: ${closeResult.error}`);
400
+ } else {
401
+ result.closed++;
402
+ }
403
+ }
404
+
405
+ // Assign if claimed
406
+ if (task.assignee) {
407
+ const assignResult = ghClient.assignIssue({
408
+ owner,
409
+ repo,
410
+ number: task.issueNumber,
411
+ assignees: [task.assignee],
412
+ });
413
+ if (assignResult.error) {
414
+ console.error(`[plan-sync] Failed to assign #${task.issueNumber}: ${assignResult.error}`);
415
+ }
416
+ }
417
+
418
+ // Set project Status field
419
+ if (statusField && matchingItem) {
420
+ const statusName = STATUS_MAP[task.status] || 'Backlog';
421
+ const currentStatus = matchingItem.fieldValues && matchingItem.fieldValues.Status;
422
+
423
+ if (currentStatus !== statusName) {
424
+ const statusOption = ghProjects.findOption(statusField, statusName);
425
+ if (statusOption) {
426
+ const setResult = ghProjects.setField({
427
+ itemId: matchingItem.itemId,
428
+ fieldId: statusField.id,
429
+ optionId: statusOption.id,
430
+ });
431
+ if (setResult.error) {
432
+ console.error(`[plan-sync] Failed to set Status for issue #${task.issueNumber}: ${setResult.error}`);
433
+ } else {
434
+ result.updated++;
435
+ }
436
+ }
437
+ }
438
+ } else if (task.status !== 'todo') {
439
+ // Still count as updated if we did something (close/assign) even without project item
440
+ result.updated++;
441
+ }
442
+ }
443
+
444
+ return result;
445
+ }
446
+
447
+ // ---------------------------------------------------------------------------
448
+ // Exports
449
+ // ---------------------------------------------------------------------------
450
+
451
+ module.exports = {
452
+ parsePlanTasks,
453
+ injectIssueMarkers,
454
+ syncPlan,
455
+ updateTaskStatuses,
456
+ };