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.
- package/.claude/commands/tlc/build.md +114 -21
- package/.claude/commands/tlc/issues.md +46 -0
- package/.claude/commands/tlc/plan.md +43 -0
- package/.claude/commands/tlc/review.md +4 -0
- package/package.json +1 -1
- 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
|
@@ -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
|
+
};
|