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,499 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
checkAuth,
|
|
4
|
+
createIssue,
|
|
5
|
+
closeIssue,
|
|
6
|
+
listIssues,
|
|
7
|
+
assignIssue,
|
|
8
|
+
addLabels,
|
|
9
|
+
createPr,
|
|
10
|
+
linkPrToIssue,
|
|
11
|
+
detectRepo,
|
|
12
|
+
} from './gh-client.js';
|
|
13
|
+
|
|
14
|
+
describe('gh-client', () => {
|
|
15
|
+
describe('checkAuth', () => {
|
|
16
|
+
it('parses gh auth status JSON correctly', () => {
|
|
17
|
+
const mockExec = vi.fn().mockReturnValue(JSON.stringify({
|
|
18
|
+
user: 'octocat',
|
|
19
|
+
oauth_token: 'gho_xxx',
|
|
20
|
+
scopes: 'repo,read:org,project',
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
const result = checkAuth({ exec: mockExec });
|
|
24
|
+
|
|
25
|
+
expect(result.authenticated).toBe(true);
|
|
26
|
+
expect(result.user).toBe('octocat');
|
|
27
|
+
expect(result.scopes).toEqual(['repo', 'read:org', 'project']);
|
|
28
|
+
expect(mockExec).toHaveBeenCalledWith(
|
|
29
|
+
expect.stringContaining('gh auth status'),
|
|
30
|
+
expect.objectContaining({ encoding: 'utf-8' })
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('detects missing project scope', () => {
|
|
35
|
+
const mockExec = vi.fn().mockReturnValue(JSON.stringify({
|
|
36
|
+
user: 'octocat',
|
|
37
|
+
oauth_token: 'gho_xxx',
|
|
38
|
+
scopes: 'repo,read:org',
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
const result = checkAuth({ exec: mockExec });
|
|
42
|
+
|
|
43
|
+
expect(result.authenticated).toBe(true);
|
|
44
|
+
expect(result.scopes).not.toContain('project');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('returns structured error when gh not installed', () => {
|
|
48
|
+
const error = new Error('command not found: gh');
|
|
49
|
+
error.stderr = Buffer.from('command not found: gh');
|
|
50
|
+
const mockExec = vi.fn().mockImplementation(() => { throw error; });
|
|
51
|
+
|
|
52
|
+
const result = checkAuth({ exec: mockExec });
|
|
53
|
+
|
|
54
|
+
expect(result.authenticated).toBe(false);
|
|
55
|
+
expect(result.error).toContain('not found');
|
|
56
|
+
expect(result.code).toBe('GH_NOT_FOUND');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('returns structured error on auth failure', () => {
|
|
60
|
+
const error = new Error('not logged in');
|
|
61
|
+
error.stderr = Buffer.from('auth login required');
|
|
62
|
+
const mockExec = vi.fn().mockImplementation(() => { throw error; });
|
|
63
|
+
|
|
64
|
+
const result = checkAuth({ exec: mockExec });
|
|
65
|
+
|
|
66
|
+
expect(result.authenticated).toBe(false);
|
|
67
|
+
expect(result.code).toBe('GH_AUTH_REQUIRED');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('createIssue', () => {
|
|
72
|
+
it('constructs correct command with all args', () => {
|
|
73
|
+
const mockExec = vi.fn().mockReturnValue(JSON.stringify({ number: 42, url: 'https://github.com/owner/repo/issues/42' }));
|
|
74
|
+
|
|
75
|
+
createIssue({
|
|
76
|
+
owner: 'myorg',
|
|
77
|
+
repo: 'myrepo',
|
|
78
|
+
title: 'Bug report',
|
|
79
|
+
body: 'Something broke',
|
|
80
|
+
labels: ['bug', 'urgent'],
|
|
81
|
+
assignees: ['alice', 'bob'],
|
|
82
|
+
exec: mockExec,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const cmd = mockExec.mock.calls[0][0];
|
|
86
|
+
expect(cmd).toContain('gh issue create');
|
|
87
|
+
expect(cmd).toContain("--repo 'myorg/myrepo'");
|
|
88
|
+
expect(cmd).toContain("--title 'Bug report'");
|
|
89
|
+
expect(cmd).toContain("--body 'Something broke'");
|
|
90
|
+
expect(cmd).toContain("--label 'bug,urgent'");
|
|
91
|
+
expect(cmd).toContain("--assignee 'alice,bob'");
|
|
92
|
+
expect(cmd).toContain('--json number,url,id');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('constructs command with only required args (no labels/assignees)', () => {
|
|
96
|
+
const mockExec = vi.fn().mockReturnValue(JSON.stringify({ number: 1, url: 'https://github.com/o/r/issues/1' }));
|
|
97
|
+
|
|
98
|
+
createIssue({
|
|
99
|
+
owner: 'o',
|
|
100
|
+
repo: 'r',
|
|
101
|
+
title: 'Simple issue',
|
|
102
|
+
body: 'Details',
|
|
103
|
+
exec: mockExec,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const cmd = mockExec.mock.calls[0][0];
|
|
107
|
+
expect(cmd).toContain('gh issue create');
|
|
108
|
+
expect(cmd).toContain("--repo 'o/r'");
|
|
109
|
+
expect(cmd).toContain("--title 'Simple issue'");
|
|
110
|
+
expect(cmd).not.toContain('--label');
|
|
111
|
+
expect(cmd).not.toContain('--assignee');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('parses JSON response for number and url', () => {
|
|
115
|
+
const mockExec = vi.fn().mockReturnValue(JSON.stringify({
|
|
116
|
+
number: 7,
|
|
117
|
+
url: 'https://github.com/owner/repo/issues/7',
|
|
118
|
+
}));
|
|
119
|
+
|
|
120
|
+
const result = createIssue({
|
|
121
|
+
owner: 'owner',
|
|
122
|
+
repo: 'repo',
|
|
123
|
+
title: 'Test',
|
|
124
|
+
body: 'Body',
|
|
125
|
+
exec: mockExec,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
expect(result.number).toBe(7);
|
|
129
|
+
expect(result.url).toBe('https://github.com/owner/repo/issues/7');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('returns structured error on failure', () => {
|
|
133
|
+
const error = new Error('API error');
|
|
134
|
+
error.stderr = Buffer.from('GraphQL: Could not resolve');
|
|
135
|
+
const mockExec = vi.fn().mockImplementation(() => { throw error; });
|
|
136
|
+
|
|
137
|
+
const result = createIssue({
|
|
138
|
+
owner: 'o',
|
|
139
|
+
repo: 'r',
|
|
140
|
+
title: 'Test',
|
|
141
|
+
body: 'Body',
|
|
142
|
+
exec: mockExec,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
expect(result.error).toBeDefined();
|
|
146
|
+
expect(result.code).toBe('GH_API_ERROR');
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('closeIssue', () => {
|
|
151
|
+
it('calls correct command', () => {
|
|
152
|
+
const mockExec = vi.fn().mockReturnValue('');
|
|
153
|
+
|
|
154
|
+
const result = closeIssue({
|
|
155
|
+
owner: 'myorg',
|
|
156
|
+
repo: 'myrepo',
|
|
157
|
+
number: 42,
|
|
158
|
+
exec: mockExec,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const cmd = mockExec.mock.calls[0][0];
|
|
162
|
+
expect(cmd).toContain('gh issue close 42');
|
|
163
|
+
expect(cmd).toContain("--repo 'myorg/myrepo'");
|
|
164
|
+
expect(result.closed).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('returns structured error on failure', () => {
|
|
168
|
+
const error = new Error('not found');
|
|
169
|
+
error.stderr = Buffer.from('issue not found');
|
|
170
|
+
const mockExec = vi.fn().mockImplementation(() => { throw error; });
|
|
171
|
+
|
|
172
|
+
const result = closeIssue({
|
|
173
|
+
owner: 'o',
|
|
174
|
+
repo: 'r',
|
|
175
|
+
number: 999,
|
|
176
|
+
exec: mockExec,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
expect(result.error).toBeDefined();
|
|
180
|
+
expect(result.code).toBe('GH_API_ERROR');
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe('listIssues', () => {
|
|
185
|
+
it('filters by labels and state', () => {
|
|
186
|
+
const mockExec = vi.fn().mockReturnValue(JSON.stringify([
|
|
187
|
+
{ number: 1, title: 'Bug', state: 'OPEN', labels: [{ name: 'bug' }], assignees: [] },
|
|
188
|
+
]));
|
|
189
|
+
|
|
190
|
+
listIssues({
|
|
191
|
+
owner: 'o',
|
|
192
|
+
repo: 'r',
|
|
193
|
+
labels: ['bug', 'p1'],
|
|
194
|
+
state: 'closed',
|
|
195
|
+
limit: 50,
|
|
196
|
+
exec: mockExec,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const cmd = mockExec.mock.calls[0][0];
|
|
200
|
+
expect(cmd).toContain("--label 'bug,p1'");
|
|
201
|
+
expect(cmd).toContain("--state 'closed'");
|
|
202
|
+
expect(cmd).toContain('--limit 50');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('defaults to state=open, limit=100', () => {
|
|
206
|
+
const mockExec = vi.fn().mockReturnValue(JSON.stringify([]));
|
|
207
|
+
|
|
208
|
+
listIssues({
|
|
209
|
+
owner: 'o',
|
|
210
|
+
repo: 'r',
|
|
211
|
+
exec: mockExec,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const cmd = mockExec.mock.calls[0][0];
|
|
215
|
+
expect(cmd).toContain("--state 'open'");
|
|
216
|
+
expect(cmd).toContain('--limit 100');
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('returns parsed array', () => {
|
|
220
|
+
const items = [
|
|
221
|
+
{ number: 1, title: 'First', state: 'OPEN', labels: [], assignees: [] },
|
|
222
|
+
{ number: 2, title: 'Second', state: 'OPEN', labels: [], assignees: [] },
|
|
223
|
+
];
|
|
224
|
+
const mockExec = vi.fn().mockReturnValue(JSON.stringify(items));
|
|
225
|
+
|
|
226
|
+
const result = listIssues({ owner: 'o', repo: 'r', exec: mockExec });
|
|
227
|
+
|
|
228
|
+
expect(result).toHaveLength(2);
|
|
229
|
+
expect(result[0].number).toBe(1);
|
|
230
|
+
expect(result[1].title).toBe('Second');
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe('assignIssue', () => {
|
|
235
|
+
it('adds multiple assignees', () => {
|
|
236
|
+
const mockExec = vi.fn().mockReturnValue('');
|
|
237
|
+
|
|
238
|
+
const result = assignIssue({
|
|
239
|
+
owner: 'o',
|
|
240
|
+
repo: 'r',
|
|
241
|
+
number: 10,
|
|
242
|
+
assignees: ['alice', 'bob', 'charlie'],
|
|
243
|
+
exec: mockExec,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const cmd = mockExec.mock.calls[0][0];
|
|
247
|
+
expect(cmd).toContain('gh issue edit 10');
|
|
248
|
+
expect(cmd).toContain("--repo 'o/r'");
|
|
249
|
+
expect(cmd).toContain("--add-assignee 'alice,bob,charlie'");
|
|
250
|
+
expect(result.assigned).toBe(true);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('returns structured error on failure', () => {
|
|
254
|
+
const error = new Error('forbidden');
|
|
255
|
+
error.stderr = Buffer.from('permission denied');
|
|
256
|
+
const mockExec = vi.fn().mockImplementation(() => { throw error; });
|
|
257
|
+
|
|
258
|
+
const result = assignIssue({
|
|
259
|
+
owner: 'o',
|
|
260
|
+
repo: 'r',
|
|
261
|
+
number: 10,
|
|
262
|
+
assignees: ['alice'],
|
|
263
|
+
exec: mockExec,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
expect(result.error).toBeDefined();
|
|
267
|
+
expect(result.code).toBe('GH_API_ERROR');
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe('addLabels', () => {
|
|
272
|
+
it('adds multiple labels', () => {
|
|
273
|
+
const mockExec = vi.fn().mockReturnValue('');
|
|
274
|
+
|
|
275
|
+
const result = addLabels({
|
|
276
|
+
owner: 'o',
|
|
277
|
+
repo: 'r',
|
|
278
|
+
number: 5,
|
|
279
|
+
labels: ['enhancement', 'priority:high'],
|
|
280
|
+
exec: mockExec,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const cmd = mockExec.mock.calls[0][0];
|
|
284
|
+
expect(cmd).toContain('gh issue edit 5');
|
|
285
|
+
expect(cmd).toContain("--repo 'o/r'");
|
|
286
|
+
expect(cmd).toContain("--add-label 'enhancement,priority:high'");
|
|
287
|
+
expect(result.labeled).toBe(true);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('returns structured error on failure', () => {
|
|
291
|
+
const error = new Error('label not found');
|
|
292
|
+
error.stderr = Buffer.from('label "unknown" not found');
|
|
293
|
+
const mockExec = vi.fn().mockImplementation(() => { throw error; });
|
|
294
|
+
|
|
295
|
+
const result = addLabels({
|
|
296
|
+
owner: 'o',
|
|
297
|
+
repo: 'r',
|
|
298
|
+
number: 5,
|
|
299
|
+
labels: ['unknown'],
|
|
300
|
+
exec: mockExec,
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
expect(result.error).toBeDefined();
|
|
304
|
+
expect(result.code).toBe('GH_API_ERROR');
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
describe('createPr', () => {
|
|
309
|
+
it('constructs correct command', () => {
|
|
310
|
+
const mockExec = vi.fn().mockReturnValue(JSON.stringify({
|
|
311
|
+
number: 99,
|
|
312
|
+
url: 'https://github.com/o/r/pull/99',
|
|
313
|
+
}));
|
|
314
|
+
|
|
315
|
+
const result = createPr({
|
|
316
|
+
owner: 'o',
|
|
317
|
+
repo: 'r',
|
|
318
|
+
title: 'Add feature X',
|
|
319
|
+
body: 'Implements feature X',
|
|
320
|
+
base: 'main',
|
|
321
|
+
head: 'feature/x',
|
|
322
|
+
exec: mockExec,
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
const cmd = mockExec.mock.calls[0][0];
|
|
326
|
+
expect(cmd).toContain('gh pr create');
|
|
327
|
+
expect(cmd).toContain("--repo 'o/r'");
|
|
328
|
+
expect(cmd).toContain("--title 'Add feature X'");
|
|
329
|
+
expect(cmd).toContain("--body 'Implements feature X'");
|
|
330
|
+
expect(cmd).toContain("--base 'main'");
|
|
331
|
+
expect(cmd).toContain("--head 'feature/x'");
|
|
332
|
+
expect(cmd).toContain('--json number,url');
|
|
333
|
+
expect(result.number).toBe(99);
|
|
334
|
+
expect(result.url).toBe('https://github.com/o/r/pull/99');
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('returns structured error on failure', () => {
|
|
338
|
+
const error = new Error('already exists');
|
|
339
|
+
error.stderr = Buffer.from('a]pull request already exists');
|
|
340
|
+
const mockExec = vi.fn().mockImplementation(() => { throw error; });
|
|
341
|
+
|
|
342
|
+
const result = createPr({
|
|
343
|
+
owner: 'o',
|
|
344
|
+
repo: 'r',
|
|
345
|
+
title: 'Test',
|
|
346
|
+
body: 'Body',
|
|
347
|
+
base: 'main',
|
|
348
|
+
head: 'branch',
|
|
349
|
+
exec: mockExec,
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
expect(result.error).toBeDefined();
|
|
353
|
+
expect(result.code).toBe('GH_API_ERROR');
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
describe('linkPrToIssue', () => {
|
|
358
|
+
it('fetches existing body and appends Closes references', () => {
|
|
359
|
+
const mockExec = vi.fn()
|
|
360
|
+
.mockReturnValueOnce(JSON.stringify({ body: 'Original PR body' }))
|
|
361
|
+
.mockReturnValueOnce('');
|
|
362
|
+
|
|
363
|
+
const result = linkPrToIssue({
|
|
364
|
+
owner: 'o',
|
|
365
|
+
repo: 'r',
|
|
366
|
+
prNumber: 99,
|
|
367
|
+
issueNumbers: [10, 15],
|
|
368
|
+
exec: mockExec,
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// First call: fetch current body
|
|
372
|
+
const viewCmd = mockExec.mock.calls[0][0];
|
|
373
|
+
expect(viewCmd).toContain('gh pr view 99');
|
|
374
|
+
expect(viewCmd).toContain("--repo 'o/r'");
|
|
375
|
+
expect(viewCmd).toContain('--json body');
|
|
376
|
+
|
|
377
|
+
// Second call: edit with appended Closes
|
|
378
|
+
const editCmd = mockExec.mock.calls[1][0];
|
|
379
|
+
expect(editCmd).toContain('gh pr edit 99');
|
|
380
|
+
expect(editCmd).toContain("--repo 'o/r'");
|
|
381
|
+
expect(editCmd).toContain('Closes #10');
|
|
382
|
+
expect(editCmd).toContain('Closes #15');
|
|
383
|
+
expect(editCmd).toContain('Original PR body');
|
|
384
|
+
expect(result.linked).toBe(true);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('handles PR with no existing body', () => {
|
|
388
|
+
const mockExec = vi.fn()
|
|
389
|
+
.mockReturnValueOnce(JSON.stringify({ body: '' }))
|
|
390
|
+
.mockReturnValueOnce('');
|
|
391
|
+
|
|
392
|
+
const result = linkPrToIssue({
|
|
393
|
+
owner: 'o',
|
|
394
|
+
repo: 'r',
|
|
395
|
+
prNumber: 50,
|
|
396
|
+
issueNumbers: [3],
|
|
397
|
+
exec: mockExec,
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
const editCmd = mockExec.mock.calls[1][0];
|
|
401
|
+
expect(editCmd).toContain('Closes #3');
|
|
402
|
+
expect(result.linked).toBe(true);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('handles PR with null body', () => {
|
|
406
|
+
const mockExec = vi.fn()
|
|
407
|
+
.mockReturnValueOnce(JSON.stringify({ body: null }))
|
|
408
|
+
.mockReturnValueOnce('');
|
|
409
|
+
|
|
410
|
+
const result = linkPrToIssue({
|
|
411
|
+
owner: 'o',
|
|
412
|
+
repo: 'r',
|
|
413
|
+
prNumber: 50,
|
|
414
|
+
issueNumbers: [3],
|
|
415
|
+
exec: mockExec,
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
const editCmd = mockExec.mock.calls[1][0];
|
|
419
|
+
expect(editCmd).toContain('Closes #3');
|
|
420
|
+
expect(result.linked).toBe(true);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it('returns structured error on failure', () => {
|
|
424
|
+
const error = new Error('not found');
|
|
425
|
+
error.stderr = Buffer.from('pull request not found');
|
|
426
|
+
const mockExec = vi.fn().mockImplementation(() => { throw error; });
|
|
427
|
+
|
|
428
|
+
const result = linkPrToIssue({
|
|
429
|
+
owner: 'o',
|
|
430
|
+
repo: 'r',
|
|
431
|
+
prNumber: 999,
|
|
432
|
+
issueNumbers: [1],
|
|
433
|
+
exec: mockExec,
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
expect(result.error).toBeDefined();
|
|
437
|
+
expect(result.code).toBe('GH_API_ERROR');
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
describe('detectRepo', () => {
|
|
442
|
+
it('extracts owner and name', () => {
|
|
443
|
+
const mockExec = vi.fn().mockReturnValue(JSON.stringify({
|
|
444
|
+
owner: { login: 'myorg' },
|
|
445
|
+
name: 'myrepo',
|
|
446
|
+
url: 'https://github.com/myorg/myrepo',
|
|
447
|
+
}));
|
|
448
|
+
|
|
449
|
+
const result = detectRepo({ exec: mockExec });
|
|
450
|
+
|
|
451
|
+
expect(result.owner).toBe('myorg');
|
|
452
|
+
expect(result.repo).toBe('myrepo');
|
|
453
|
+
expect(result.url).toBe('https://github.com/myorg/myrepo');
|
|
454
|
+
expect(mockExec).toHaveBeenCalledWith(
|
|
455
|
+
expect.stringContaining('gh repo view'),
|
|
456
|
+
expect.objectContaining({ encoding: 'utf-8' })
|
|
457
|
+
);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it('returns null when not a GitHub repo', () => {
|
|
461
|
+
const error = new Error('not a git repository');
|
|
462
|
+
error.stderr = Buffer.from('not a git repository');
|
|
463
|
+
const mockExec = vi.fn().mockImplementation(() => { throw error; });
|
|
464
|
+
|
|
465
|
+
const result = detectRepo({ exec: mockExec });
|
|
466
|
+
|
|
467
|
+
expect(result).toBeNull();
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
describe('error handling', () => {
|
|
472
|
+
it('all errors return structured { error, code } objects, never throw', () => {
|
|
473
|
+
const error = new Error('something went wrong');
|
|
474
|
+
error.stderr = Buffer.from('unexpected error');
|
|
475
|
+
const failExec = vi.fn().mockImplementation(() => { throw error; });
|
|
476
|
+
|
|
477
|
+
// None of these should throw
|
|
478
|
+
const results = [
|
|
479
|
+
checkAuth({ exec: failExec }),
|
|
480
|
+
createIssue({ owner: 'o', repo: 'r', title: 't', body: 'b', exec: failExec }),
|
|
481
|
+
closeIssue({ owner: 'o', repo: 'r', number: 1, exec: failExec }),
|
|
482
|
+
listIssues({ owner: 'o', repo: 'r', exec: failExec }),
|
|
483
|
+
assignIssue({ owner: 'o', repo: 'r', number: 1, assignees: ['a'], exec: failExec }),
|
|
484
|
+
addLabels({ owner: 'o', repo: 'r', number: 1, labels: ['l'], exec: failExec }),
|
|
485
|
+
createPr({ owner: 'o', repo: 'r', title: 't', body: 'b', base: 'main', head: 'h', exec: failExec }),
|
|
486
|
+
linkPrToIssue({ owner: 'o', repo: 'r', prNumber: 1, issueNumbers: [1], exec: failExec }),
|
|
487
|
+
];
|
|
488
|
+
|
|
489
|
+
for (const result of results) {
|
|
490
|
+
expect(result).toHaveProperty('error');
|
|
491
|
+
expect(result).toHaveProperty('code');
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// detectRepo returns null instead of error object
|
|
495
|
+
const detectResult = detectRepo({ exec: failExec });
|
|
496
|
+
expect(detectResult).toBeNull();
|
|
497
|
+
});
|
|
498
|
+
});
|
|
499
|
+
});
|