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.
Files changed (86) hide show
  1. package/.claude/commands/tlc/autofix.md +34 -1
  2. package/.claude/commands/tlc/build.md +203 -27
  3. package/.claude/commands/tlc/ci.md +178 -414
  4. package/.claude/commands/tlc/coverage.md +34 -0
  5. package/.claude/commands/tlc/deploy.md +19 -6
  6. package/.claude/commands/tlc/discuss.md +34 -0
  7. package/.claude/commands/tlc/docs.md +35 -1
  8. package/.claude/commands/tlc/e2e.md +300 -0
  9. package/.claude/commands/tlc/edge-cases.md +35 -1
  10. package/.claude/commands/tlc/init.md +38 -8
  11. package/.claude/commands/tlc/issues.md +46 -0
  12. package/.claude/commands/tlc/new-project.md +46 -4
  13. package/.claude/commands/tlc/plan.md +76 -0
  14. package/.claude/commands/tlc/quick.md +33 -0
  15. package/.claude/commands/tlc/release.md +85 -135
  16. package/.claude/commands/tlc/restore.md +14 -0
  17. package/.claude/commands/tlc/review.md +80 -1
  18. package/.claude/commands/tlc/tlc.md +134 -0
  19. package/.claude/commands/tlc/verify.md +64 -65
  20. package/.claude/commands/tlc/watchci.md +10 -0
  21. package/.claude/hooks/tlc-block-tools.sh +13 -0
  22. package/.claude/hooks/tlc-session-init.sh +9 -0
  23. package/CODING-STANDARDS.md +35 -10
  24. package/package.json +1 -1
  25. package/server/lib/block-tools-hook.js +23 -0
  26. package/server/lib/e2e/acceptance-parser.js +132 -0
  27. package/server/lib/e2e/acceptance-parser.test.js +110 -0
  28. package/server/lib/e2e/framework-detector.js +47 -0
  29. package/server/lib/e2e/framework-detector.test.js +94 -0
  30. package/server/lib/e2e/log-assertions.js +107 -0
  31. package/server/lib/e2e/log-assertions.test.js +68 -0
  32. package/server/lib/e2e/test-generator.js +159 -0
  33. package/server/lib/e2e/test-generator.test.js +121 -0
  34. package/server/lib/e2e/verify-runner.js +191 -0
  35. package/server/lib/e2e/verify-runner.test.js +167 -0
  36. package/server/lib/github/config.js +458 -0
  37. package/server/lib/github/config.test.js +385 -0
  38. package/server/lib/github/gh-client.js +303 -0
  39. package/server/lib/github/gh-client.test.js +499 -0
  40. package/server/lib/github/gh-projects.js +594 -0
  41. package/server/lib/github/gh-projects.test.js +583 -0
  42. package/server/lib/github/index.js +19 -0
  43. package/server/lib/github/plan-sync.js +456 -0
  44. package/server/lib/github/plan-sync.test.js +805 -0
  45. package/server/lib/hooks/block-tools-hook.test.js +54 -0
  46. package/server/lib/orchestration/cli-dispatch.js +16 -1
  47. package/server/lib/orchestration/cli-dispatch.test.js +94 -8
  48. package/server/lib/orchestration/completion-checker.js +101 -0
  49. package/server/lib/orchestration/completion-checker.test.js +177 -0
  50. package/server/lib/orchestration/result-verifier.js +143 -0
  51. package/server/lib/orchestration/result-verifier.test.js +291 -0
  52. package/server/lib/orchestration/session-dispatcher.js +99 -0
  53. package/server/lib/orchestration/session-dispatcher.test.js +215 -0
  54. package/server/lib/orchestration/session-status.js +147 -0
  55. package/server/lib/orchestration/session-status.test.js +130 -0
  56. package/server/lib/release/agent-runner-updates.js +24 -0
  57. package/server/lib/release/agent-runner-updates.test.js +22 -0
  58. package/server/lib/release/changelog-generator.js +142 -0
  59. package/server/lib/release/changelog-generator.test.js +113 -0
  60. package/server/lib/release/ci-watcher.js +83 -0
  61. package/server/lib/release/ci-watcher.test.js +81 -0
  62. package/server/lib/release/health-checker.js +111 -0
  63. package/server/lib/release/health-checker.test.js +121 -0
  64. package/server/lib/release/release-pipeline.js +187 -0
  65. package/server/lib/release/release-pipeline.test.js +262 -0
  66. package/server/lib/release/version-bumper.js +183 -0
  67. package/server/lib/release/version-bumper.test.js +142 -0
  68. package/server/lib/routing-preamble.integration.test.js +12 -0
  69. package/server/lib/routing-preamble.js +13 -2
  70. package/server/lib/routing-preamble.test.js +49 -0
  71. package/server/lib/scaffolding/ci-detector.js +139 -0
  72. package/server/lib/scaffolding/ci-detector.test.js +198 -0
  73. package/server/lib/scaffolding/ci-scaffolder.js +347 -0
  74. package/server/lib/scaffolding/ci-scaffolder.test.js +157 -0
  75. package/server/lib/scaffolding/deploy-detector.js +135 -0
  76. package/server/lib/scaffolding/deploy-detector.test.js +106 -0
  77. package/server/lib/scaffolding/health-scaffold.js +374 -0
  78. package/server/lib/scaffolding/health-scaffold.test.js +99 -0
  79. package/server/lib/scaffolding/logger-scaffold.js +196 -0
  80. package/server/lib/scaffolding/logger-scaffold.test.js +146 -0
  81. package/server/lib/scaffolding/migration-detector.js +78 -0
  82. package/server/lib/scaffolding/migration-detector.test.js +127 -0
  83. package/server/lib/scaffolding/snapshot-manager.js +142 -0
  84. package/server/lib/scaffolding/snapshot-manager.test.js +225 -0
  85. package/server/lib/task-router-config.js +50 -20
  86. package/server/lib/task-router-config.test.js +29 -15
@@ -0,0 +1,385 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import {
3
+ loadGitHubConfig,
4
+ isGitHubEnabled,
5
+ detectAndSuggestConfig,
6
+ writeGitHubConfig,
7
+ getDefaultConfig,
8
+ queueSyncAction,
9
+ flushSyncQueue,
10
+ loadSyncQueue,
11
+ } from './config.js';
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Mock fs helpers
15
+ // ---------------------------------------------------------------------------
16
+
17
+ function createMockFs(files = {}) {
18
+ return {
19
+ existsSync: vi.fn((path) => path in files),
20
+ readFileSync: vi.fn((path, _encoding) => {
21
+ if (!(path in files)) {
22
+ const err = new Error(`ENOENT: no such file or directory, open '${path}'`);
23
+ err.code = 'ENOENT';
24
+ throw err;
25
+ }
26
+ return files[path];
27
+ }),
28
+ writeFileSync: vi.fn(),
29
+ mkdirSync: vi.fn(),
30
+ };
31
+ }
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // loadGitHubConfig
35
+ // ---------------------------------------------------------------------------
36
+
37
+ describe('loadGitHubConfig', () => {
38
+ it('reads valid config', () => {
39
+ const tlcJson = JSON.stringify({
40
+ project: 'MyProject',
41
+ github: {
42
+ autoSync: true,
43
+ owner: 'myorg',
44
+ repo: 'myrepo',
45
+ project: 'My Board',
46
+ },
47
+ });
48
+ const fs = createMockFs({ '/proj/.tlc.json': tlcJson });
49
+
50
+ const result = loadGitHubConfig('/proj', { fs });
51
+
52
+ expect(result.config).toBeTruthy();
53
+ expect(result.config.autoSync).toBe(true);
54
+ expect(result.config.owner).toBe('myorg');
55
+ expect(result.config.repo).toBe('myrepo');
56
+ expect(result.warnings).toEqual([]);
57
+ });
58
+
59
+ it('returns null when no github section', () => {
60
+ const tlcJson = JSON.stringify({ project: 'NoGitHub' });
61
+ const fs = createMockFs({ '/proj/.tlc.json': tlcJson });
62
+
63
+ const result = loadGitHubConfig('/proj', { fs });
64
+
65
+ expect(result.config).toBeNull();
66
+ expect(result.warnings).toEqual([]);
67
+ });
68
+
69
+ it('warns on missing project field', () => {
70
+ const tlcJson = JSON.stringify({
71
+ project: 'Test',
72
+ github: {
73
+ autoSync: true,
74
+ owner: 'myorg',
75
+ repo: 'myrepo',
76
+ // project field missing
77
+ },
78
+ });
79
+ const fs = createMockFs({ '/proj/.tlc.json': tlcJson });
80
+
81
+ const result = loadGitHubConfig('/proj', { fs });
82
+
83
+ expect(result.config).toBeTruthy();
84
+ expect(result.warnings.length).toBeGreaterThan(0);
85
+ expect(result.warnings[0]).toContain('project');
86
+ });
87
+ });
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // isGitHubEnabled
91
+ // ---------------------------------------------------------------------------
92
+
93
+ describe('isGitHubEnabled', () => {
94
+ it('returns true when autoSync is true', () => {
95
+ const tlcJson = JSON.stringify({
96
+ github: { autoSync: true },
97
+ });
98
+ const fs = createMockFs({ '/proj/.tlc.json': tlcJson });
99
+
100
+ expect(isGitHubEnabled('/proj', { fs })).toBe(true);
101
+ });
102
+
103
+ it('returns false when no config', () => {
104
+ const fs = createMockFs({}); // no .tlc.json file
105
+
106
+ expect(isGitHubEnabled('/proj', { fs })).toBe(false);
107
+ });
108
+
109
+ it('returns false when autoSync is false', () => {
110
+ const tlcJson = JSON.stringify({
111
+ github: { autoSync: false },
112
+ });
113
+ const fs = createMockFs({ '/proj/.tlc.json': tlcJson });
114
+
115
+ expect(isGitHubEnabled('/proj', { fs })).toBe(false);
116
+ });
117
+ });
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // detectAndSuggestConfig
121
+ // ---------------------------------------------------------------------------
122
+
123
+ describe('detectAndSuggestConfig', () => {
124
+ it('detects repo and project', () => {
125
+ const ghClient = {
126
+ detectRepo: vi.fn().mockReturnValue({ owner: 'myorg', repo: 'myrepo', url: 'https://github.com/myorg/myrepo' }),
127
+ checkAuth: vi.fn().mockReturnValue({ authenticated: true, user: 'octocat', scopes: ['repo', 'project'] }),
128
+ };
129
+ const ghProjects = {
130
+ discoverProject: vi.fn().mockReturnValue({
131
+ projectId: 'PVT_123',
132
+ title: 'myrepo',
133
+ number: 1,
134
+ fields: [
135
+ { id: 'F1', name: 'Status', type: 'single_select', options: [{ id: 'O1', name: 'Backlog' }] },
136
+ { id: 'F2', name: 'Sprint', type: 'single_select', options: [] },
137
+ ],
138
+ }),
139
+ };
140
+ const fs = createMockFs({});
141
+
142
+ const result = detectAndSuggestConfig({ ghClient, ghProjects, projectDir: '/proj', fs });
143
+
144
+ expect(result.owner).toBe('myorg');
145
+ expect(result.repo).toBe('myrepo');
146
+ expect(result.project).toBeTruthy();
147
+ expect(result.suggestedConfig).toBeTruthy();
148
+ expect(result.suggestedConfig.autoSync).toBe(true);
149
+ });
150
+
151
+ it('detects missing project scope', () => {
152
+ const ghClient = {
153
+ detectRepo: vi.fn().mockReturnValue({ owner: 'myorg', repo: 'myrepo', url: 'https://github.com/myorg/myrepo' }),
154
+ checkAuth: vi.fn().mockReturnValue({ authenticated: true, user: 'octocat', scopes: ['repo'] }),
155
+ };
156
+ const ghProjects = {};
157
+ const fs = createMockFs({});
158
+
159
+ const result = detectAndSuggestConfig({ ghClient, ghProjects, projectDir: '/proj', fs });
160
+
161
+ expect(result.needsScope).toBe(true);
162
+ expect(result.command).toContain('gh auth refresh');
163
+ expect(result.command).toContain('project');
164
+ });
165
+
166
+ it('returns suggestedConfig with defaults', () => {
167
+ const ghClient = {
168
+ detectRepo: vi.fn().mockReturnValue({ owner: 'org', repo: 'repo', url: 'https://github.com/org/repo' }),
169
+ checkAuth: vi.fn().mockReturnValue({ authenticated: true, user: 'user', scopes: ['repo', 'project'] }),
170
+ };
171
+ const ghProjects = {
172
+ discoverProject: vi.fn().mockReturnValue({
173
+ projectId: 'PVT_456',
174
+ title: 'repo',
175
+ number: 2,
176
+ fields: [],
177
+ }),
178
+ };
179
+ const fs = createMockFs({});
180
+
181
+ const result = detectAndSuggestConfig({ ghClient, ghProjects, projectDir: '/proj', fs });
182
+
183
+ expect(result.suggestedConfig).toBeTruthy();
184
+ expect(result.suggestedConfig.statusMapping).toBeTruthy();
185
+ expect(result.suggestedConfig.statusMapping.todo).toBe('Backlog');
186
+ expect(result.suggestedConfig.phasePrefix).toBe('Phase');
187
+ });
188
+
189
+ it('handles no repo gracefully', () => {
190
+ const ghClient = {
191
+ detectRepo: vi.fn().mockReturnValue(null),
192
+ checkAuth: vi.fn(),
193
+ };
194
+ const ghProjects = {};
195
+ const fs = createMockFs({});
196
+
197
+ const result = detectAndSuggestConfig({ ghClient, ghProjects, projectDir: '/proj', fs });
198
+
199
+ expect(result.error).toBeTruthy();
200
+ expect(result.error).toContain('repo');
201
+ });
202
+ });
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // writeGitHubConfig
206
+ // ---------------------------------------------------------------------------
207
+
208
+ describe('writeGitHubConfig', () => {
209
+ it('preserves existing .tlc.json content', () => {
210
+ const existing = JSON.stringify({
211
+ project: 'MyProject',
212
+ version: '1.0.0',
213
+ quality: { coverageThreshold: 80 },
214
+ });
215
+ const fs = createMockFs({ '/proj/.tlc.json': existing });
216
+
217
+ const githubConfig = { autoSync: true, owner: 'org', repo: 'repo' };
218
+ const result = writeGitHubConfig('/proj', githubConfig, { fs });
219
+
220
+ expect(result.written).toBe(true);
221
+ expect(fs.writeFileSync).toHaveBeenCalledTimes(1);
222
+
223
+ const writtenContent = JSON.parse(fs.writeFileSync.mock.calls[0][1]);
224
+ expect(writtenContent.project).toBe('MyProject');
225
+ expect(writtenContent.version).toBe('1.0.0');
226
+ expect(writtenContent.quality.coverageThreshold).toBe(80);
227
+ expect(writtenContent.github.autoSync).toBe(true);
228
+ expect(writtenContent.github.owner).toBe('org');
229
+ });
230
+
231
+ it('creates github section if missing', () => {
232
+ const existing = JSON.stringify({ project: 'Bare' });
233
+ const fs = createMockFs({ '/proj/.tlc.json': existing });
234
+
235
+ const githubConfig = { autoSync: true };
236
+ const result = writeGitHubConfig('/proj', githubConfig, { fs });
237
+
238
+ expect(result.written).toBe(true);
239
+ const writtenContent = JSON.parse(fs.writeFileSync.mock.calls[0][1]);
240
+ expect(writtenContent.github).toEqual({ autoSync: true });
241
+ });
242
+ });
243
+
244
+ // ---------------------------------------------------------------------------
245
+ // getDefaultConfig
246
+ // ---------------------------------------------------------------------------
247
+
248
+ describe('getDefaultConfig', () => {
249
+ it('returns expected defaults', () => {
250
+ const defaults = getDefaultConfig();
251
+
252
+ expect(defaults.autoSync).toBe(true);
253
+ expect(defaults.phasePrefix).toBe('Phase');
254
+ expect(defaults.sprintField).toBe('Sprint');
255
+ expect(defaults.statusField).toBe('Status');
256
+ expect(defaults.statusMapping).toEqual({
257
+ todo: 'Backlog',
258
+ in_progress: 'In progress',
259
+ in_review: 'In review',
260
+ done: 'Done',
261
+ });
262
+ });
263
+ });
264
+
265
+ // ---------------------------------------------------------------------------
266
+ // queueSyncAction + loadSyncQueue
267
+ // ---------------------------------------------------------------------------
268
+
269
+ describe('queueSyncAction', () => {
270
+ it('creates queue file', () => {
271
+ const fs = createMockFs({});
272
+
273
+ queueSyncAction('/proj', { type: 'create_issue', payload: { title: 'Bug' }, timestamp: '2026-01-01T00:00:00Z' }, { fs });
274
+
275
+ expect(fs.mkdirSync).toHaveBeenCalled();
276
+ expect(fs.writeFileSync).toHaveBeenCalledTimes(1);
277
+ const written = JSON.parse(fs.writeFileSync.mock.calls[0][1]);
278
+ expect(written).toHaveLength(1);
279
+ expect(written[0].type).toBe('create_issue');
280
+ expect(written[0].payload.title).toBe('Bug');
281
+ });
282
+
283
+ it('appends to existing queue', () => {
284
+ const existingQueue = JSON.stringify([
285
+ { type: 'close_issue', payload: { number: 1 }, timestamp: '2026-01-01T00:00:00Z' },
286
+ ]);
287
+ const fs = createMockFs({ '/proj/.tlc/.github-sync-queue.json': existingQueue });
288
+
289
+ queueSyncAction('/proj', { type: 'create_issue', payload: { title: 'New' }, timestamp: '2026-01-02T00:00:00Z' }, { fs });
290
+
291
+ const written = JSON.parse(fs.writeFileSync.mock.calls[0][1]);
292
+ expect(written).toHaveLength(2);
293
+ expect(written[0].type).toBe('close_issue');
294
+ expect(written[1].type).toBe('create_issue');
295
+ });
296
+ });
297
+
298
+ describe('loadSyncQueue', () => {
299
+ it('returns empty array when no file', () => {
300
+ const fs = createMockFs({});
301
+
302
+ const result = loadSyncQueue('/proj', { fs });
303
+
304
+ expect(result).toEqual([]);
305
+ });
306
+
307
+ it('returns queued actions', () => {
308
+ const queue = JSON.stringify([
309
+ { type: 'create_issue', payload: { title: 'A' }, timestamp: '2026-01-01T00:00:00Z' },
310
+ { type: 'close_issue', payload: { number: 5 }, timestamp: '2026-01-01T01:00:00Z' },
311
+ ]);
312
+ const fs = createMockFs({ '/proj/.tlc/.github-sync-queue.json': queue });
313
+
314
+ const result = loadSyncQueue('/proj', { fs });
315
+
316
+ expect(result).toHaveLength(2);
317
+ expect(result[0].type).toBe('create_issue');
318
+ expect(result[1].payload.number).toBe(5);
319
+ });
320
+ });
321
+
322
+ // ---------------------------------------------------------------------------
323
+ // flushSyncQueue
324
+ // ---------------------------------------------------------------------------
325
+
326
+ describe('flushSyncQueue', () => {
327
+ it('replays and clears successful actions', () => {
328
+ const queue = JSON.stringify([
329
+ { type: 'create_issue', payload: { owner: 'o', repo: 'r', title: 'Bug', body: 'desc' }, timestamp: '2026-01-01T00:00:00Z' },
330
+ { type: 'close_issue', payload: { owner: 'o', repo: 'r', number: 3 }, timestamp: '2026-01-01T01:00:00Z' },
331
+ ]);
332
+ const fs = createMockFs({ '/proj/.tlc/.github-sync-queue.json': queue });
333
+ const ghClient = {
334
+ createIssue: vi.fn().mockReturnValue({ number: 10, url: 'https://github.com/o/r/issues/10' }),
335
+ closeIssue: vi.fn().mockReturnValue({ closed: true }),
336
+ };
337
+ const ghProjects = {};
338
+
339
+ const result = flushSyncQueue('/proj', { ghClient, ghProjects, fs });
340
+
341
+ expect(result.flushed).toBe(2);
342
+ expect(result.failed).toBe(0);
343
+ expect(result.remaining).toBe(0);
344
+
345
+ // Should write empty queue (or remove file)
346
+ const written = JSON.parse(fs.writeFileSync.mock.calls[0][1]);
347
+ expect(written).toHaveLength(0);
348
+ });
349
+
350
+ it('retains failed actions', () => {
351
+ const queue = JSON.stringify([
352
+ { type: 'create_issue', payload: { owner: 'o', repo: 'r', title: 'Good', body: 'ok' }, timestamp: '2026-01-01T00:00:00Z' },
353
+ { type: 'create_issue', payload: { owner: 'o', repo: 'r', title: 'Bad', body: 'fail' }, timestamp: '2026-01-01T01:00:00Z' },
354
+ ]);
355
+ const fs = createMockFs({ '/proj/.tlc/.github-sync-queue.json': queue });
356
+ const ghClient = {
357
+ createIssue: vi.fn()
358
+ .mockReturnValueOnce({ number: 10, url: 'https://github.com/o/r/issues/10' })
359
+ .mockReturnValueOnce({ error: 'API error', code: 'GH_API_ERROR' }),
360
+ };
361
+ const ghProjects = {};
362
+
363
+ const result = flushSyncQueue('/proj', { ghClient, ghProjects, fs });
364
+
365
+ expect(result.flushed).toBe(1);
366
+ expect(result.failed).toBe(1);
367
+ expect(result.remaining).toBe(1);
368
+
369
+ const written = JSON.parse(fs.writeFileSync.mock.calls[0][1]);
370
+ expect(written).toHaveLength(1);
371
+ expect(written[0].payload.title).toBe('Bad');
372
+ });
373
+
374
+ it('returns correct counts', () => {
375
+ const fs = createMockFs({}); // no queue file
376
+ const ghClient = {};
377
+ const ghProjects = {};
378
+
379
+ const result = flushSyncQueue('/proj', { ghClient, ghProjects, fs });
380
+
381
+ expect(result.flushed).toBe(0);
382
+ expect(result.failed).toBe(0);
383
+ expect(result.remaining).toBe(0);
384
+ });
385
+ });
@@ -0,0 +1,303 @@
1
+ /**
2
+ * GitHub Client — wraps `gh` CLI for issue and PR operations
3
+ * Phase 97 Task 1
4
+ *
5
+ * All functions accept `{ exec }` for dependency injection,
6
+ * defaulting to child_process.execSync.
7
+ */
8
+
9
+ const { execSync } = require('child_process');
10
+
11
+ const EXEC_OPTS = { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] };
12
+
13
+ /**
14
+ * Escape a string for safe use inside single-quoted shell arguments.
15
+ * Wraps in single quotes and escapes any embedded single quotes.
16
+ * @param {string} str
17
+ * @returns {string}
18
+ */
19
+ function shellEscape(str) {
20
+ if (str == null) return "''";
21
+ return "'" + String(str).replace(/'/g, "'\\''") + "'";
22
+ }
23
+
24
+ /**
25
+ * Extract error message from a caught error
26
+ * @param {Error} error
27
+ * @returns {string}
28
+ */
29
+ function extractMessage(error) {
30
+ return error.stderr?.toString() || error.message;
31
+ }
32
+
33
+ /**
34
+ * Classify an error into a structured response
35
+ * @param {string} message - Error message text
36
+ * @param {string} context - Human-readable context for logging
37
+ * @param {Object} [meta] - Additional metadata to log
38
+ * @returns {{ error: string, code: string }}
39
+ */
40
+ function classifyError(message, context, meta = {}) {
41
+ if (message.includes('command not found') || message.includes('not found: gh') || message.includes('ENOENT')) {
42
+ return { error: 'gh CLI not found', code: 'GH_NOT_FOUND' };
43
+ }
44
+ if (message.includes('auth') || message.includes('login')) {
45
+ return { error: 'gh not authenticated', code: 'GH_AUTH_REQUIRED' };
46
+ }
47
+ console.error(`[TLC] GitHub ${context} failed: ${message}`, meta);
48
+ return { error: message, code: 'GH_API_ERROR' };
49
+ }
50
+
51
+ /**
52
+ * Check GitHub CLI authentication status
53
+ * @param {Object} [options]
54
+ * @param {Function} [options.exec] - Injected exec function
55
+ * @returns {{ authenticated: boolean, user?: string, scopes?: string[], error?: string, code?: string }}
56
+ */
57
+ function checkAuth({ exec = execSync } = {}) {
58
+ try {
59
+ const raw = exec('gh auth status --json', EXEC_OPTS);
60
+ const data = JSON.parse(raw);
61
+ const scopes = Array.isArray(data.scopes) ? data.scopes
62
+ : typeof data.scopes === 'string' ? data.scopes.split(',').map(s => s.trim())
63
+ : [];
64
+ return {
65
+ authenticated: true,
66
+ user: data.user,
67
+ scopes,
68
+ };
69
+ } catch (error) {
70
+ const message = extractMessage(error);
71
+ const result = classifyError(message, 'checkAuth', {});
72
+ return { authenticated: false, ...result };
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Create a GitHub issue
78
+ * @param {Object} options
79
+ * @param {string} options.owner - Repository owner
80
+ * @param {string} options.repo - Repository name
81
+ * @param {string} options.title - Issue title
82
+ * @param {string} options.body - Issue body
83
+ * @param {string[]} [options.labels] - Labels to add
84
+ * @param {string[]} [options.assignees] - Assignees to add
85
+ * @param {Function} [options.exec] - Injected exec function
86
+ * @returns {{ number: number, url: string } | { error: string, code: string }}
87
+ */
88
+ function createIssue({ owner, repo, title, body, labels, assignees, exec = execSync }) {
89
+ if (!owner || !repo || !title) {
90
+ return { error: 'Missing required parameters: owner, repo, title', code: 'INVALID_PARAMS' };
91
+ }
92
+ try {
93
+ let cmd = `gh issue create --repo ${shellEscape(owner + '/' + repo)} --title ${shellEscape(title)} --body ${shellEscape(body || '')}`;
94
+ if (labels && labels.length > 0) {
95
+ cmd += ` --label ${shellEscape(labels.join(','))}`;
96
+ }
97
+ if (assignees && assignees.length > 0) {
98
+ cmd += ` --assignee ${shellEscape(assignees.join(','))}`;
99
+ }
100
+ cmd += ' --json number,url,id';
101
+ const raw = exec(cmd, EXEC_OPTS);
102
+ return JSON.parse(raw);
103
+ } catch (error) {
104
+ const message = extractMessage(error);
105
+ return classifyError(message, 'createIssue', { owner, repo, title });
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Close a GitHub issue
111
+ * @param {Object} options
112
+ * @param {string} options.owner - Repository owner
113
+ * @param {string} options.repo - Repository name
114
+ * @param {number} options.number - Issue number
115
+ * @param {Function} [options.exec] - Injected exec function
116
+ * @returns {{ closed: boolean } | { error: string, code: string }}
117
+ */
118
+ function closeIssue({ owner, repo, number, exec = execSync }) {
119
+ if (!owner || !repo || number == null) {
120
+ return { error: 'Missing required parameters: owner, repo, number', code: 'INVALID_PARAMS' };
121
+ }
122
+ try {
123
+ const cmd = `gh issue close ${Number(number)} --repo ${shellEscape(owner + '/' + repo)}`;
124
+ exec(cmd, EXEC_OPTS);
125
+ return { closed: true };
126
+ } catch (error) {
127
+ const message = extractMessage(error);
128
+ return classifyError(message, 'closeIssue', { owner, repo, number });
129
+ }
130
+ }
131
+
132
+ /**
133
+ * List GitHub issues
134
+ * @param {Object} options
135
+ * @param {string} options.owner - Repository owner
136
+ * @param {string} options.repo - Repository name
137
+ * @param {string[]} [options.labels] - Filter by labels
138
+ * @param {string} [options.state='open'] - Issue state filter
139
+ * @param {number} [options.limit=100] - Maximum issues to return
140
+ * @param {Function} [options.exec] - Injected exec function
141
+ * @returns {Array | { error: string, code: string }}
142
+ */
143
+ function listIssues({ owner, repo, labels, state = 'open', limit = 100, exec = execSync }) {
144
+ if (!owner || !repo) {
145
+ return { error: 'Missing required parameters: owner, repo', code: 'INVALID_PARAMS' };
146
+ }
147
+ try {
148
+ let cmd = `gh issue list --repo ${shellEscape(owner + '/' + repo)} --state ${shellEscape(state)} --json number,title,state,labels,assignees --limit ${Number(limit)}`;
149
+ if (labels && labels.length > 0) {
150
+ cmd += ` --label ${shellEscape(labels.join(','))}`;
151
+ }
152
+ const raw = exec(cmd, EXEC_OPTS);
153
+ return JSON.parse(raw);
154
+ } catch (error) {
155
+ const message = extractMessage(error);
156
+ return classifyError(message, 'listIssues', { owner, repo, state });
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Assign users to a GitHub issue
162
+ * @param {Object} options
163
+ * @param {string} options.owner - Repository owner
164
+ * @param {string} options.repo - Repository name
165
+ * @param {number} options.number - Issue number
166
+ * @param {string[]} options.assignees - Users to assign
167
+ * @param {Function} [options.exec] - Injected exec function
168
+ * @returns {{ assigned: boolean } | { error: string, code: string }}
169
+ */
170
+ function assignIssue({ owner, repo, number, assignees, exec = execSync }) {
171
+ if (!owner || !repo || number == null || !assignees || assignees.length === 0) {
172
+ return { error: 'Missing required parameters: owner, repo, number, assignees', code: 'INVALID_PARAMS' };
173
+ }
174
+ try {
175
+ const cmd = `gh issue edit ${Number(number)} --repo ${shellEscape(owner + '/' + repo)} --add-assignee ${shellEscape(assignees.join(','))}`;
176
+ exec(cmd, EXEC_OPTS);
177
+ return { assigned: true };
178
+ } catch (error) {
179
+ const message = extractMessage(error);
180
+ return classifyError(message, 'assignIssue', { owner, repo, number });
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Add labels to a GitHub issue
186
+ * @param {Object} options
187
+ * @param {string} options.owner - Repository owner
188
+ * @param {string} options.repo - Repository name
189
+ * @param {number} options.number - Issue number
190
+ * @param {string[]} options.labels - Labels to add
191
+ * @param {Function} [options.exec] - Injected exec function
192
+ * @returns {{ labeled: boolean } | { error: string, code: string }}
193
+ */
194
+ function addLabels({ owner, repo, number, labels, exec = execSync }) {
195
+ if (!owner || !repo || number == null || !labels || labels.length === 0) {
196
+ return { error: 'Missing required parameters: owner, repo, number, labels', code: 'INVALID_PARAMS' };
197
+ }
198
+ try {
199
+ const cmd = `gh issue edit ${Number(number)} --repo ${shellEscape(owner + '/' + repo)} --add-label ${shellEscape(labels.join(','))}`;
200
+ exec(cmd, EXEC_OPTS);
201
+ return { labeled: true };
202
+ } catch (error) {
203
+ const message = extractMessage(error);
204
+ return classifyError(message, 'addLabels', { owner, repo, number });
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Create a pull request
210
+ * @param {Object} options
211
+ * @param {string} options.owner - Repository owner
212
+ * @param {string} options.repo - Repository name
213
+ * @param {string} options.title - PR title
214
+ * @param {string} options.body - PR body
215
+ * @param {string} options.base - Base branch
216
+ * @param {string} options.head - Head branch
217
+ * @param {Function} [options.exec] - Injected exec function
218
+ * @returns {{ number: number, url: string } | { error: string, code: string }}
219
+ */
220
+ function createPr({ owner, repo, title, body, base, head, exec = execSync }) {
221
+ if (!owner || !repo || !title || !base || !head) {
222
+ return { error: 'Missing required parameters: owner, repo, title, base, head', code: 'INVALID_PARAMS' };
223
+ }
224
+ try {
225
+ const cmd = `gh pr create --repo ${shellEscape(owner + '/' + repo)} --title ${shellEscape(title)} --body ${shellEscape(body || '')} --base ${shellEscape(base)} --head ${shellEscape(head)} --json number,url`;
226
+ const raw = exec(cmd, EXEC_OPTS);
227
+ return JSON.parse(raw);
228
+ } catch (error) {
229
+ const message = extractMessage(error);
230
+ return classifyError(message, 'createPr', { owner, repo, title });
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Link a PR to issues by appending Closes references to the PR body
236
+ * @param {Object} options
237
+ * @param {string} options.owner - Repository owner
238
+ * @param {string} options.repo - Repository name
239
+ * @param {number} options.prNumber - PR number
240
+ * @param {number[]} options.issueNumbers - Issue numbers to link
241
+ * @param {Function} [options.exec] - Injected exec function
242
+ * @returns {{ linked: boolean } | { error: string, code: string }}
243
+ */
244
+ function linkPrToIssue({ owner, repo, prNumber, issueNumbers, exec = execSync }) {
245
+ if (!owner || !repo || prNumber == null || !issueNumbers || issueNumbers.length === 0) {
246
+ return { error: 'Missing required parameters: owner, repo, prNumber, issueNumbers', code: 'INVALID_PARAMS' };
247
+ }
248
+ try {
249
+ // Fetch current PR body
250
+ const viewCmd = `gh pr view ${Number(prNumber)} --repo ${shellEscape(owner + '/' + repo)} --json body`;
251
+ const viewRaw = exec(viewCmd, EXEC_OPTS);
252
+ const { body: currentBody } = JSON.parse(viewRaw);
253
+
254
+ // Build new body with Closes references
255
+ const closesRefs = issueNumbers.map(n => `Closes #${Number(n)}`).join(', ');
256
+ const existingBody = currentBody || '';
257
+ const newBody = existingBody
258
+ ? `${existingBody}\n\n${closesRefs}`
259
+ : closesRefs;
260
+
261
+ // Update PR body
262
+ const editCmd = `gh pr edit ${Number(prNumber)} --repo ${shellEscape(owner + '/' + repo)} --body ${shellEscape(newBody)}`;
263
+ exec(editCmd, EXEC_OPTS);
264
+ return { linked: true };
265
+ } catch (error) {
266
+ const message = extractMessage(error);
267
+ return classifyError(message, 'linkPrToIssue', { owner, repo, prNumber });
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Detect the current repository from git context
273
+ * @param {Object} [options]
274
+ * @param {Function} [options.exec] - Injected exec function
275
+ * @returns {{ owner: string, repo: string, url: string } | null}
276
+ */
277
+ function detectRepo({ exec = execSync } = {}) {
278
+ try {
279
+ const raw = exec('gh repo view --json owner,name,url', EXEC_OPTS);
280
+ const data = JSON.parse(raw);
281
+ return {
282
+ owner: data.owner?.login || data.owner,
283
+ repo: data.name,
284
+ url: data.url,
285
+ };
286
+ } catch (error) {
287
+ const message = extractMessage(error);
288
+ console.error(`[TLC] GitHub detectRepo failed: ${message}`);
289
+ return null;
290
+ }
291
+ }
292
+
293
+ module.exports = {
294
+ checkAuth,
295
+ createIssue,
296
+ closeIssue,
297
+ listIssues,
298
+ assignIssue,
299
+ addLabels,
300
+ createPr,
301
+ linkPrToIssue,
302
+ detectRepo,
303
+ };