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,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
|
+
};
|