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,458 @@
1
+ /**
2
+ * GitHub Config — configuration management, setup detection, and offline queue
3
+ * Phase 97 Task 5
4
+ *
5
+ * All functions accept `{ fs }` for dependency injection,
6
+ * defaulting to the Node.js `fs` module.
7
+ *
8
+ * Error handling: no silent failures. Every catch logs with context or rethrows.
9
+ *
10
+ * @module github/config
11
+ */
12
+
13
+ const nodeFs = require('fs');
14
+ const path = require('path');
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Paths
18
+ // ---------------------------------------------------------------------------
19
+
20
+ const TLC_JSON = '.tlc.json';
21
+ const QUEUE_DIR = '.tlc';
22
+ const QUEUE_FILE = '.github-sync-queue.json';
23
+
24
+ /**
25
+ * Resolve the .tlc.json path for a project directory.
26
+ * @param {string} projectDir
27
+ * @returns {string}
28
+ */
29
+ function tlcJsonPath(projectDir) {
30
+ return path.join(projectDir, TLC_JSON);
31
+ }
32
+
33
+ /**
34
+ * Resolve the sync queue file path.
35
+ * @param {string} projectDir
36
+ * @returns {string}
37
+ */
38
+ function queueFilePath(projectDir) {
39
+ return path.join(projectDir, QUEUE_DIR, QUEUE_FILE);
40
+ }
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // getDefaultConfig
44
+ // ---------------------------------------------------------------------------
45
+
46
+ /**
47
+ * Return the default GitHub integration config object.
48
+ * @returns {object}
49
+ */
50
+ function getDefaultConfig() {
51
+ return {
52
+ autoSync: true,
53
+ phasePrefix: 'Phase',
54
+ sprintField: 'Sprint',
55
+ statusField: 'Status',
56
+ statusMapping: {
57
+ todo: 'Backlog',
58
+ in_progress: 'In progress',
59
+ in_review: 'In review',
60
+ done: 'Done',
61
+ },
62
+ };
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // loadGitHubConfig
67
+ // ---------------------------------------------------------------------------
68
+
69
+ /**
70
+ * Read `.tlc.json` from projectDir and return the `github` section.
71
+ * Validates required fields and collects warnings.
72
+ *
73
+ * @param {string} projectDir - Absolute path to the project root
74
+ * @param {object} [options]
75
+ * @param {object} [options.fs] - Injected fs module
76
+ * @returns {{ config: object|null, warnings: string[] }}
77
+ */
78
+ function loadGitHubConfig(projectDir, { fs = nodeFs } = {}) {
79
+ const filePath = tlcJsonPath(projectDir);
80
+ const warnings = [];
81
+
82
+ let raw;
83
+ try {
84
+ raw = fs.readFileSync(filePath, 'utf-8');
85
+ } catch (err) {
86
+ if (err.code === 'ENOENT') {
87
+ return { config: null, warnings: [] };
88
+ }
89
+ console.error(`[TLC] loadGitHubConfig: failed to read ${filePath}: ${err.message}`);
90
+ throw err;
91
+ }
92
+
93
+ let parsed;
94
+ try {
95
+ parsed = JSON.parse(raw);
96
+ } catch (err) {
97
+ console.error(`[TLC] loadGitHubConfig: invalid JSON in ${filePath}: ${err.message}`);
98
+ return { config: null, warnings: [`Invalid JSON in ${TLC_JSON}: ${err.message}`] };
99
+ }
100
+
101
+ if (!parsed.github) {
102
+ return { config: null, warnings: [] };
103
+ }
104
+
105
+ const config = parsed.github;
106
+
107
+ // Validate expected fields
108
+ if (!config.project) {
109
+ warnings.push('Missing "project" field in github config — GitHub Projects integration will not work');
110
+ }
111
+
112
+ return { config, warnings };
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // isGitHubEnabled
117
+ // ---------------------------------------------------------------------------
118
+
119
+ /**
120
+ * Quick synchronous check: does `.tlc.json` have `github.autoSync` set to true?
121
+ * Designed to be fast (<1ms). Returns false on any error.
122
+ *
123
+ * @param {string} projectDir - Absolute path to the project root
124
+ * @param {object} [options]
125
+ * @param {object} [options.fs] - Injected fs module
126
+ * @returns {boolean}
127
+ */
128
+ function isGitHubEnabled(projectDir, { fs = nodeFs } = {}) {
129
+ const filePath = tlcJsonPath(projectDir);
130
+
131
+ try {
132
+ if (!fs.existsSync(filePath)) {
133
+ return false;
134
+ }
135
+ const raw = fs.readFileSync(filePath, 'utf-8');
136
+ const parsed = JSON.parse(raw);
137
+ return parsed.github?.autoSync === true;
138
+ } catch (err) {
139
+ console.error(`[TLC] isGitHubEnabled: error reading ${filePath}: ${err.message}`);
140
+ return false;
141
+ }
142
+ }
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // detectAndSuggestConfig
146
+ // ---------------------------------------------------------------------------
147
+
148
+ /**
149
+ * Auto-detection flow for `/tlc:issues setup`.
150
+ *
151
+ * 1. Calls ghClient.detectRepo() to get owner/repo
152
+ * 2. Calls ghClient.checkAuth() to verify auth and scopes
153
+ * 3. If missing `project` scope, returns `{ needsScope, command }`
154
+ * 4. Tries to find a matching GitHub Project (by repo name or org)
155
+ * 5. Returns `{ owner, repo, project, fields, suggestedConfig }`
156
+ *
157
+ * @param {object} params
158
+ * @param {object} params.ghClient - GitHub client (detectRepo, checkAuth)
159
+ * @param {object} params.ghProjects - GitHub Projects client (discoverProject)
160
+ * @param {string} params.projectDir - Absolute path to the project root
161
+ * @param {object} [params.fs] - Injected fs module
162
+ * @returns {object} Detection result
163
+ */
164
+ function detectAndSuggestConfig({ ghClient, ghProjects, projectDir, fs = nodeFs }) {
165
+ // Step 1: Detect repo
166
+ const repoInfo = ghClient.detectRepo();
167
+ if (!repoInfo) {
168
+ return { error: 'Could not detect a GitHub repo in this directory. Run from a git repository with a GitHub remote.' };
169
+ }
170
+
171
+ const { owner, repo } = repoInfo;
172
+
173
+ // Step 2: Check auth + scopes
174
+ const authResult = ghClient.checkAuth();
175
+ if (!authResult.authenticated) {
176
+ return { error: `GitHub CLI not authenticated: ${authResult.error || 'unknown'}`, code: authResult.code };
177
+ }
178
+
179
+ const scopes = authResult.scopes || [];
180
+ if (!scopes.includes('project')) {
181
+ return {
182
+ needsScope: true,
183
+ command: 'gh auth refresh -s project',
184
+ owner,
185
+ repo,
186
+ message: 'Missing "project" scope. Run the command below to add it.',
187
+ };
188
+ }
189
+
190
+ // Step 3: Try to discover a matching GitHub Project
191
+ let project = null;
192
+ let fields = [];
193
+
194
+ // Try by org first, then by user
195
+ const discovered = ghProjects.discoverProject({ org: owner, projectTitle: repo });
196
+ if (discovered && !discovered.error) {
197
+ project = discovered;
198
+ fields = discovered.fields || [];
199
+ } else {
200
+ // Try as user
201
+ const discoveredUser = ghProjects.discoverProject({ user: owner, projectTitle: repo });
202
+ if (discoveredUser && !discoveredUser.error) {
203
+ project = discoveredUser;
204
+ fields = discoveredUser.fields || [];
205
+ }
206
+ }
207
+
208
+ // Step 4: Build suggested config
209
+ const defaults = getDefaultConfig();
210
+ const suggestedConfig = {
211
+ ...defaults,
212
+ owner,
213
+ repo,
214
+ project: project ? project.title : null,
215
+ projectId: project ? project.projectId : null,
216
+ };
217
+
218
+ return {
219
+ owner,
220
+ repo,
221
+ project,
222
+ fields,
223
+ suggestedConfig,
224
+ };
225
+ }
226
+
227
+ // ---------------------------------------------------------------------------
228
+ // writeGitHubConfig
229
+ // ---------------------------------------------------------------------------
230
+
231
+ /**
232
+ * Read existing `.tlc.json`, add/update the `github` key, write back.
233
+ * Preserves all other configuration.
234
+ *
235
+ * @param {string} projectDir - Absolute path to the project root
236
+ * @param {object} githubConfig - The github config object to write
237
+ * @param {object} [options]
238
+ * @param {object} [options.fs] - Injected fs module
239
+ * @returns {{ written: boolean }}
240
+ */
241
+ function writeGitHubConfig(projectDir, githubConfig, { fs = nodeFs } = {}) {
242
+ const filePath = tlcJsonPath(projectDir);
243
+
244
+ let existing = {};
245
+ try {
246
+ const raw = fs.readFileSync(filePath, 'utf-8');
247
+ existing = JSON.parse(raw);
248
+ } catch (err) {
249
+ if (err.code !== 'ENOENT') {
250
+ console.error(`[TLC] writeGitHubConfig: error reading ${filePath}: ${err.message}`);
251
+ throw err;
252
+ }
253
+ // File doesn't exist yet, start from empty object
254
+ }
255
+
256
+ existing.github = githubConfig;
257
+
258
+ fs.writeFileSync(filePath, JSON.stringify(existing, null, 2) + '\n', 'utf-8');
259
+ return { written: true };
260
+ }
261
+
262
+ // ---------------------------------------------------------------------------
263
+ // queueSyncAction
264
+ // ---------------------------------------------------------------------------
265
+
266
+ /**
267
+ * Append an action to the offline sync queue.
268
+ * Creates the queue file and directory if they don't exist.
269
+ *
270
+ * @param {string} projectDir - Absolute path to the project root
271
+ * @param {object} action - `{ type, payload, timestamp }`
272
+ * @param {object} [options]
273
+ * @param {object} [options.fs] - Injected fs module
274
+ */
275
+ function queueSyncAction(projectDir, action, { fs = nodeFs } = {}) {
276
+ const dirPath = path.join(projectDir, QUEUE_DIR);
277
+ const filePath = queueFilePath(projectDir);
278
+
279
+ try {
280
+ // Ensure directory exists
281
+ fs.mkdirSync(dirPath, { recursive: true });
282
+ } catch (err) {
283
+ console.error(`[TLC] queueSyncAction: cannot create queue dir ${dirPath}: ${err.message}`);
284
+ return { error: `Cannot create queue directory: ${err.message}`, code: 'QUEUE_WRITE_ERROR' };
285
+ }
286
+
287
+ // Load existing queue
288
+ let queue = [];
289
+ try {
290
+ if (fs.existsSync(filePath)) {
291
+ const raw = fs.readFileSync(filePath, 'utf-8');
292
+ queue = JSON.parse(raw);
293
+ }
294
+ } catch (err) {
295
+ if (err.code === 'ENOENT') {
296
+ queue = [];
297
+ } else {
298
+ console.error(`[TLC] queueSyncAction: error reading queue at ${filePath}: ${err.message}. Refusing to overwrite.`);
299
+ return { error: 'Queue file corrupted', code: 'QUEUE_CORRUPT' };
300
+ }
301
+ }
302
+
303
+ queue.push(action);
304
+ try {
305
+ fs.writeFileSync(filePath, JSON.stringify(queue, null, 2), 'utf-8');
306
+ } catch (err) {
307
+ console.error(`[TLC] queueSyncAction: cannot write queue at ${filePath}: ${err.message}`);
308
+ return { error: `Cannot write queue file: ${err.message}`, code: 'QUEUE_WRITE_ERROR' };
309
+ }
310
+ }
311
+
312
+ // ---------------------------------------------------------------------------
313
+ // loadSyncQueue
314
+ // ---------------------------------------------------------------------------
315
+
316
+ /**
317
+ * Read and return the sync queue array, or empty array if no file exists.
318
+ *
319
+ * @param {string} projectDir - Absolute path to the project root
320
+ * @param {object} [options]
321
+ * @param {object} [options.fs] - Injected fs module
322
+ * @returns {Array | { queue: Array, error: string }}
323
+ */
324
+ function loadSyncQueue(projectDir, { fs = nodeFs } = {}) {
325
+ const filePath = queueFilePath(projectDir);
326
+
327
+ try {
328
+ if (!fs.existsSync(filePath)) {
329
+ return [];
330
+ }
331
+ const raw = fs.readFileSync(filePath, 'utf-8');
332
+ return JSON.parse(raw);
333
+ } catch (err) {
334
+ console.error(`[TLC] loadSyncQueue: error reading queue at ${filePath}: ${err.message}`);
335
+ return { queue: [], error: 'Queue file corrupted or unreadable' };
336
+ }
337
+ }
338
+
339
+ // ---------------------------------------------------------------------------
340
+ // flushSyncQueue
341
+ // ---------------------------------------------------------------------------
342
+
343
+ /**
344
+ * Replay action dispatcher — maps queue action types to client calls.
345
+ * @param {object} action - Queue action `{ type, payload }`
346
+ * @param {object} ghClient - GitHub client
347
+ * @param {object} ghProjects - GitHub Projects client
348
+ * @returns {{ success: boolean, result?: object }}
349
+ */
350
+ function replayAction(action, ghClient, ghProjects) {
351
+ const { type, payload } = action;
352
+
353
+ try {
354
+ let result;
355
+ switch (type) {
356
+ case 'create_issue':
357
+ result = ghClient.createIssue(payload);
358
+ break;
359
+ case 'close_issue':
360
+ result = ghClient.closeIssue(payload);
361
+ break;
362
+ case 'assign_issue':
363
+ result = ghClient.assignIssue(payload);
364
+ break;
365
+ case 'add_labels':
366
+ result = ghClient.addLabels(payload);
367
+ break;
368
+ case 'add_to_project':
369
+ result = ghProjects.addItemToProject(payload);
370
+ break;
371
+ case 'set_field':
372
+ result = ghProjects.setFieldValue(payload);
373
+ break;
374
+ default:
375
+ console.error(`[TLC] flushSyncQueue: unknown action type "${type}"`);
376
+ return { success: false };
377
+ }
378
+
379
+ // Check for structured error responses
380
+ if (result && result.error) {
381
+ console.error(`[TLC] flushSyncQueue: action "${type}" failed: ${result.error} (code: ${result.code})`);
382
+ return { success: false, result };
383
+ }
384
+
385
+ return { success: true, result };
386
+ } catch (err) {
387
+ console.error(`[TLC] flushSyncQueue: action "${type}" threw: ${err.message}`);
388
+ return { success: false };
389
+ }
390
+ }
391
+
392
+ /**
393
+ * Read queue, replay each action, remove successful ones, keep failed ones.
394
+ *
395
+ * @param {string} projectDir - Absolute path to the project root
396
+ * @param {object} params
397
+ * @param {object} params.ghClient - GitHub client
398
+ * @param {object} params.ghProjects - GitHub Projects client
399
+ * @param {object} [params.fs] - Injected fs module
400
+ * @returns {{ flushed: number, failed: number, remaining: number }}
401
+ */
402
+ function flushSyncQueue(projectDir, { ghClient, ghProjects, fs = nodeFs }) {
403
+ const loaded = loadSyncQueue(projectDir, { fs });
404
+
405
+ // loadSyncQueue returns an array on success, or { queue, error } on corruption
406
+ if (loaded && loaded.error) {
407
+ console.error(`[TLC] flushSyncQueue: ${loaded.error}`);
408
+ return { flushed: 0, failed: 0, remaining: 0, error: loaded.error };
409
+ }
410
+
411
+ const queue = loaded;
412
+
413
+ if (queue.length === 0) {
414
+ return { flushed: 0, failed: 0, remaining: 0 };
415
+ }
416
+
417
+ let flushed = 0;
418
+ let failed = 0;
419
+ const remaining = [];
420
+
421
+ for (const action of queue) {
422
+ const { success } = replayAction(action, ghClient, ghProjects);
423
+ if (success) {
424
+ flushed++;
425
+ } else {
426
+ failed++;
427
+ remaining.push(action);
428
+ }
429
+ }
430
+
431
+ // Write back remaining (failed) actions
432
+ const filePath = queueFilePath(projectDir);
433
+ const dirPath = path.join(projectDir, QUEUE_DIR);
434
+ try {
435
+ fs.mkdirSync(dirPath, { recursive: true });
436
+ fs.writeFileSync(filePath, JSON.stringify(remaining, null, 2), 'utf-8');
437
+ } catch (err) {
438
+ console.error(`[TLC] flushSyncQueue: cannot write remaining queue at ${filePath}: ${err.message}`);
439
+ return { flushed, failed, remaining: remaining.length, error: `Cannot write queue file: ${err.message}` };
440
+ }
441
+
442
+ return { flushed, failed, remaining: remaining.length };
443
+ }
444
+
445
+ // ---------------------------------------------------------------------------
446
+ // Exports
447
+ // ---------------------------------------------------------------------------
448
+
449
+ module.exports = {
450
+ loadGitHubConfig,
451
+ isGitHubEnabled,
452
+ detectAndSuggestConfig,
453
+ writeGitHubConfig,
454
+ getDefaultConfig,
455
+ queueSyncAction,
456
+ flushSyncQueue,
457
+ loadSyncQueue,
458
+ };