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,583 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file gh-projects.test.js
|
|
3
|
+
* @description Tests for GitHub Projects V2 GraphQL client (Phase 97, Task 2).
|
|
4
|
+
*
|
|
5
|
+
* Tests the module that manages GitHub Projects V2 via GraphQL —
|
|
6
|
+
* discover project schema, add items, set field values.
|
|
7
|
+
*
|
|
8
|
+
* All exec calls are mocked via DI. GraphQL calls go through `gh api graphql`.
|
|
9
|
+
*
|
|
10
|
+
* TDD: RED phase — these tests are written BEFORE the implementation.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
13
|
+
import {
|
|
14
|
+
discoverProject,
|
|
15
|
+
addItemToProject,
|
|
16
|
+
setFieldValue,
|
|
17
|
+
createFieldOption,
|
|
18
|
+
getProjectItems,
|
|
19
|
+
findFieldByName,
|
|
20
|
+
findOptionByName,
|
|
21
|
+
createProjectsClient,
|
|
22
|
+
} from './gh-projects.js';
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Mock data factories
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
function makeProjectNode({ id = 'PVT_abc123', title = 'TLC Board', number = 1 } = {}) {
|
|
29
|
+
return {
|
|
30
|
+
id,
|
|
31
|
+
title,
|
|
32
|
+
number,
|
|
33
|
+
fields: {
|
|
34
|
+
nodes: [
|
|
35
|
+
{ __typename: 'ProjectV2Field', id: 'PVTF_title', name: 'Title' },
|
|
36
|
+
{
|
|
37
|
+
__typename: 'ProjectV2SingleSelectField',
|
|
38
|
+
id: 'PVTSSF_status',
|
|
39
|
+
name: 'Status',
|
|
40
|
+
options: [
|
|
41
|
+
{ id: 'opt_todo', name: 'Todo' },
|
|
42
|
+
{ id: 'opt_inprogress', name: 'In Progress' },
|
|
43
|
+
{ id: 'opt_done', name: 'Done' },
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
__typename: 'ProjectV2SingleSelectField',
|
|
48
|
+
id: 'PVTSSF_sprint',
|
|
49
|
+
name: 'Sprint',
|
|
50
|
+
options: [
|
|
51
|
+
{ id: 'opt_phase96', name: 'Phase-96' },
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
{ __typename: 'ProjectV2Field', id: 'PVTF_assignees', name: 'Assignees' },
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function makeOrgDiscoveryResponse(projects = [makeProjectNode()]) {
|
|
61
|
+
return JSON.stringify({
|
|
62
|
+
data: {
|
|
63
|
+
organization: {
|
|
64
|
+
projectsV2: {
|
|
65
|
+
nodes: projects,
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function makeUserDiscoveryResponse(projects = [makeProjectNode()]) {
|
|
73
|
+
return JSON.stringify({
|
|
74
|
+
data: {
|
|
75
|
+
user: {
|
|
76
|
+
projectsV2: {
|
|
77
|
+
nodes: projects,
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function makeAddItemResponse(itemId = 'PVTI_item1') {
|
|
85
|
+
return JSON.stringify({
|
|
86
|
+
data: {
|
|
87
|
+
addProjectV2ItemById: {
|
|
88
|
+
item: { id: itemId },
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function makeSetFieldResponse(itemId = 'PVTI_item1') {
|
|
95
|
+
return JSON.stringify({
|
|
96
|
+
data: {
|
|
97
|
+
updateProjectV2ItemFieldValue: {
|
|
98
|
+
projectV2Item: { id: itemId },
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function makeCreateOptionResponse(optionId = 'opt_new1') {
|
|
105
|
+
return JSON.stringify({
|
|
106
|
+
data: {
|
|
107
|
+
createProjectV2FieldOption: {
|
|
108
|
+
projectV2Field: {
|
|
109
|
+
options: [
|
|
110
|
+
{ id: 'opt_existing', name: 'Existing' },
|
|
111
|
+
{ id: optionId, name: 'Phase-97' },
|
|
112
|
+
],
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function makeProjectItemsResponse() {
|
|
120
|
+
return JSON.stringify({
|
|
121
|
+
data: {
|
|
122
|
+
node: {
|
|
123
|
+
items: {
|
|
124
|
+
nodes: [
|
|
125
|
+
{
|
|
126
|
+
id: 'PVTI_item1',
|
|
127
|
+
content: {
|
|
128
|
+
__typename: 'Issue',
|
|
129
|
+
id: 'I_issue1',
|
|
130
|
+
title: 'Fix login bug',
|
|
131
|
+
},
|
|
132
|
+
fieldValues: {
|
|
133
|
+
nodes: [
|
|
134
|
+
{ __typename: 'ProjectV2ItemFieldTextValue', text: 'Fix login bug', field: { name: 'Title' } },
|
|
135
|
+
{ __typename: 'ProjectV2ItemFieldSingleSelectValue', name: 'In Progress', field: { name: 'Status' } },
|
|
136
|
+
{ __typename: 'ProjectV2ItemFieldSingleSelectValue', name: 'Phase-96', field: { name: 'Sprint' } },
|
|
137
|
+
],
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
id: 'PVTI_item2',
|
|
142
|
+
content: {
|
|
143
|
+
__typename: 'PullRequest',
|
|
144
|
+
id: 'PR_pr1',
|
|
145
|
+
title: 'Add auth module',
|
|
146
|
+
},
|
|
147
|
+
fieldValues: {
|
|
148
|
+
nodes: [
|
|
149
|
+
{ __typename: 'ProjectV2ItemFieldTextValue', text: 'Add auth module', field: { name: 'Title' } },
|
|
150
|
+
{ __typename: 'ProjectV2ItemFieldSingleSelectValue', name: 'Done', field: { name: 'Status' } },
|
|
151
|
+
],
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function makeExec(response) {
|
|
162
|
+
return vi.fn().mockReturnValue(Buffer.from(response));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function makeExecThrowing(stderr, exitCode = 1) {
|
|
166
|
+
return vi.fn().mockImplementation(() => {
|
|
167
|
+
const err = new Error(`Command failed with exit code ${exitCode}`);
|
|
168
|
+
err.stderr = Buffer.from(stderr);
|
|
169
|
+
err.status = exitCode;
|
|
170
|
+
throw err;
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// discoverProject
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
describe('discoverProject', () => {
|
|
179
|
+
it('finds project by title in org', async () => {
|
|
180
|
+
const exec = makeExec(makeOrgDiscoveryResponse());
|
|
181
|
+
const result = discoverProject({ org: 'KashaTech', projectTitle: 'TLC Board', exec });
|
|
182
|
+
|
|
183
|
+
expect(result.projectId).toBe('PVT_abc123');
|
|
184
|
+
expect(result.title).toBe('TLC Board');
|
|
185
|
+
expect(result.number).toBe(1);
|
|
186
|
+
expect(result.fields).toBeInstanceOf(Array);
|
|
187
|
+
expect(result.fields.length).toBeGreaterThan(0);
|
|
188
|
+
|
|
189
|
+
// Verify the exec was called with a graphql query containing organization
|
|
190
|
+
const call = exec.mock.calls[0][0];
|
|
191
|
+
expect(call).toContain('gh api graphql');
|
|
192
|
+
expect(call).toContain('organization');
|
|
193
|
+
expect(call).toContain('KashaTech');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('finds project by title for personal user', () => {
|
|
197
|
+
const exec = makeExec(makeUserDiscoveryResponse());
|
|
198
|
+
const result = discoverProject({ user: 'octocat', projectTitle: 'TLC Board', exec });
|
|
199
|
+
|
|
200
|
+
expect(result.projectId).toBe('PVT_abc123');
|
|
201
|
+
expect(result.title).toBe('TLC Board');
|
|
202
|
+
|
|
203
|
+
const call = exec.mock.calls[0][0];
|
|
204
|
+
expect(call).toContain('user');
|
|
205
|
+
expect(call).toContain('octocat');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('returns error when project not found', () => {
|
|
209
|
+
const response = JSON.stringify({
|
|
210
|
+
data: {
|
|
211
|
+
organization: {
|
|
212
|
+
projectsV2: { nodes: [] },
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
const exec = makeExec(response);
|
|
217
|
+
const result = discoverProject({ org: 'KashaTech', projectTitle: 'Nonexistent', exec });
|
|
218
|
+
|
|
219
|
+
expect(result.error).toBeDefined();
|
|
220
|
+
expect(result.code).toBe('GH_PROJECT_NOT_FOUND');
|
|
221
|
+
expect(result.error).toContain('Nonexistent');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('extracts fields with options correctly', () => {
|
|
225
|
+
const exec = makeExec(makeOrgDiscoveryResponse());
|
|
226
|
+
const result = discoverProject({ org: 'KashaTech', projectTitle: 'TLC Board', exec });
|
|
227
|
+
|
|
228
|
+
// Status field should have options
|
|
229
|
+
const statusField = result.fields.find(f => f.name === 'Status');
|
|
230
|
+
expect(statusField).toBeDefined();
|
|
231
|
+
expect(statusField.type).toBe('single_select');
|
|
232
|
+
expect(statusField.options).toEqual([
|
|
233
|
+
{ id: 'opt_todo', name: 'Todo' },
|
|
234
|
+
{ id: 'opt_inprogress', name: 'In Progress' },
|
|
235
|
+
{ id: 'opt_done', name: 'Done' },
|
|
236
|
+
]);
|
|
237
|
+
|
|
238
|
+
// Title field should NOT have options
|
|
239
|
+
const titleField = result.fields.find(f => f.name === 'Title');
|
|
240
|
+
expect(titleField).toBeDefined();
|
|
241
|
+
expect(titleField.type).toBe('field');
|
|
242
|
+
expect(titleField.options).toBeUndefined();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('returns error when gh is not installed', () => {
|
|
246
|
+
const exec = makeExecThrowing('command not found: gh');
|
|
247
|
+
const result = discoverProject({ org: 'KashaTech', projectTitle: 'TLC Board', exec });
|
|
248
|
+
|
|
249
|
+
expect(result.error).toBeDefined();
|
|
250
|
+
expect(result.code).toBe('GH_NOT_FOUND');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('returns error when not authenticated', () => {
|
|
254
|
+
const exec = makeExecThrowing('gh auth login');
|
|
255
|
+
const result = discoverProject({ org: 'KashaTech', projectTitle: 'TLC Board', exec });
|
|
256
|
+
|
|
257
|
+
expect(result.error).toBeDefined();
|
|
258
|
+
expect(result.code).toBe('GH_AUTH_REQUIRED');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('returns actionable error when project scope is missing', () => {
|
|
262
|
+
const exec = makeExecThrowing('Your token does not have the required scopes: project');
|
|
263
|
+
const result = discoverProject({ org: 'KashaTech', projectTitle: 'TLC Board', exec });
|
|
264
|
+
|
|
265
|
+
expect(result.error).toBeDefined();
|
|
266
|
+
expect(result.code).toBe('GH_SCOPE_MISSING');
|
|
267
|
+
expect(result.error).toContain('gh auth refresh');
|
|
268
|
+
expect(result.error).toContain('project');
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
// addItemToProject
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
describe('addItemToProject', () => {
|
|
277
|
+
it('constructs correct GraphQL mutation', () => {
|
|
278
|
+
const exec = makeExec(makeAddItemResponse());
|
|
279
|
+
addItemToProject({ projectId: 'PVT_abc123', contentId: 'I_issue1', exec });
|
|
280
|
+
|
|
281
|
+
const call = exec.mock.calls[0][0];
|
|
282
|
+
expect(call).toContain('addProjectV2ItemById');
|
|
283
|
+
expect(call).toContain('PVT_abc123');
|
|
284
|
+
expect(call).toContain('I_issue1');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('returns itemId from response', () => {
|
|
288
|
+
const exec = makeExec(makeAddItemResponse('PVTI_xyz'));
|
|
289
|
+
const result = addItemToProject({ projectId: 'PVT_abc123', contentId: 'I_issue1', exec });
|
|
290
|
+
|
|
291
|
+
expect(result.itemId).toBe('PVTI_xyz');
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('returns error on failure', () => {
|
|
295
|
+
const exec = makeExecThrowing('Something went wrong');
|
|
296
|
+
const result = addItemToProject({ projectId: 'PVT_abc123', contentId: 'I_issue1', exec });
|
|
297
|
+
|
|
298
|
+
expect(result.error).toBeDefined();
|
|
299
|
+
expect(result.code).toBeDefined();
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
// setFieldValue
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
describe('setFieldValue', () => {
|
|
308
|
+
it('constructs correct mutation with optionId', () => {
|
|
309
|
+
const exec = makeExec(makeSetFieldResponse());
|
|
310
|
+
const result = setFieldValue({
|
|
311
|
+
projectId: 'PVT_abc123',
|
|
312
|
+
itemId: 'PVTI_item1',
|
|
313
|
+
fieldId: 'PVTSSF_status',
|
|
314
|
+
optionId: 'opt_inprogress',
|
|
315
|
+
exec,
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
const call = exec.mock.calls[0][0];
|
|
319
|
+
expect(call).toContain('updateProjectV2ItemFieldValue');
|
|
320
|
+
expect(call).toContain('PVT_abc123');
|
|
321
|
+
expect(call).toContain('PVTI_item1');
|
|
322
|
+
expect(call).toContain('PVTSSF_status');
|
|
323
|
+
expect(call).toContain('opt_inprogress');
|
|
324
|
+
expect(call).toContain('singleSelectOptionId');
|
|
325
|
+
|
|
326
|
+
expect(result.success).toBe(true);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('returns error on failure', () => {
|
|
330
|
+
const exec = makeExecThrowing('Resource not accessible by integration');
|
|
331
|
+
const result = setFieldValue({
|
|
332
|
+
projectId: 'PVT_abc123',
|
|
333
|
+
itemId: 'PVTI_item1',
|
|
334
|
+
fieldId: 'PVTSSF_status',
|
|
335
|
+
optionId: 'opt_inprogress',
|
|
336
|
+
exec,
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
expect(result.error).toBeDefined();
|
|
340
|
+
expect(result.code).toBeDefined();
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
// createFieldOption
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
describe('createFieldOption', () => {
|
|
349
|
+
it('constructs correct mutation', () => {
|
|
350
|
+
const exec = makeExec(makeCreateOptionResponse());
|
|
351
|
+
const result = createFieldOption({
|
|
352
|
+
projectId: 'PVT_abc123',
|
|
353
|
+
fieldId: 'PVTSSF_sprint',
|
|
354
|
+
name: 'Phase-97',
|
|
355
|
+
exec,
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
const call = exec.mock.calls[0][0];
|
|
359
|
+
expect(call).toContain('createProjectV2FieldOption');
|
|
360
|
+
expect(call).toContain('PVT_abc123');
|
|
361
|
+
expect(call).toContain('PVTSSF_sprint');
|
|
362
|
+
expect(call).toContain('Phase-97');
|
|
363
|
+
|
|
364
|
+
expect(result.optionId).toBe('opt_new1');
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('returns error on permission denied', () => {
|
|
368
|
+
const exec = makeExecThrowing('Resource not accessible by integration');
|
|
369
|
+
const result = createFieldOption({
|
|
370
|
+
projectId: 'PVT_abc123',
|
|
371
|
+
fieldId: 'PVTSSF_sprint',
|
|
372
|
+
name: 'Phase-97',
|
|
373
|
+
exec,
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
expect(result.error).toBeDefined();
|
|
377
|
+
expect(result.code).toBe('GH_PERMISSION_DENIED');
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
// ---------------------------------------------------------------------------
|
|
382
|
+
// getProjectItems
|
|
383
|
+
// ---------------------------------------------------------------------------
|
|
384
|
+
|
|
385
|
+
describe('getProjectItems', () => {
|
|
386
|
+
it('returns items with field values', () => {
|
|
387
|
+
const exec = makeExec(makeProjectItemsResponse());
|
|
388
|
+
const result = getProjectItems({ projectId: 'PVT_abc123', first: 50, exec });
|
|
389
|
+
|
|
390
|
+
expect(result).toBeInstanceOf(Array);
|
|
391
|
+
expect(result).toHaveLength(2);
|
|
392
|
+
|
|
393
|
+
const item1 = result[0];
|
|
394
|
+
expect(item1.itemId).toBe('PVTI_item1');
|
|
395
|
+
expect(item1.contentId).toBe('I_issue1');
|
|
396
|
+
expect(item1.contentType).toBe('Issue');
|
|
397
|
+
expect(item1.title).toBe('Fix login bug');
|
|
398
|
+
expect(item1.fieldValues).toBeDefined();
|
|
399
|
+
expect(item1.fieldValues.Status).toBe('In Progress');
|
|
400
|
+
expect(item1.fieldValues.Sprint).toBe('Phase-96');
|
|
401
|
+
|
|
402
|
+
const item2 = result[1];
|
|
403
|
+
expect(item2.itemId).toBe('PVTI_item2');
|
|
404
|
+
expect(item2.contentId).toBe('PR_pr1');
|
|
405
|
+
expect(item2.contentType).toBe('PullRequest');
|
|
406
|
+
expect(item2.title).toBe('Add auth module');
|
|
407
|
+
expect(item2.fieldValues.Status).toBe('Done');
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it('returns error on failure', () => {
|
|
411
|
+
const exec = makeExecThrowing('not found');
|
|
412
|
+
const result = getProjectItems({ projectId: 'PVT_abc123', exec });
|
|
413
|
+
|
|
414
|
+
expect(result.error).toBeDefined();
|
|
415
|
+
expect(result.code).toBeDefined();
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
// ---------------------------------------------------------------------------
|
|
420
|
+
// findFieldByName (pure helper)
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
|
|
423
|
+
describe('findFieldByName', () => {
|
|
424
|
+
const fields = [
|
|
425
|
+
{ id: 'PVTF_title', name: 'Title', type: 'field' },
|
|
426
|
+
{ id: 'PVTSSF_status', name: 'Status', type: 'single_select', options: [{ id: 'opt_todo', name: 'Todo' }] },
|
|
427
|
+
{ id: 'PVTSSF_sprint', name: 'Sprint', type: 'single_select', options: [] },
|
|
428
|
+
];
|
|
429
|
+
|
|
430
|
+
it('returns correct field by name', () => {
|
|
431
|
+
const result = findFieldByName(fields, 'Status');
|
|
432
|
+
expect(result).toBeDefined();
|
|
433
|
+
expect(result.id).toBe('PVTSSF_status');
|
|
434
|
+
expect(result.name).toBe('Status');
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it('returns null for missing field', () => {
|
|
438
|
+
const result = findFieldByName(fields, 'Priority');
|
|
439
|
+
expect(result).toBeNull();
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it('is case-insensitive', () => {
|
|
443
|
+
const result = findFieldByName(fields, 'status');
|
|
444
|
+
expect(result).toBeDefined();
|
|
445
|
+
expect(result.id).toBe('PVTSSF_status');
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
// ---------------------------------------------------------------------------
|
|
450
|
+
// findOptionByName (pure helper)
|
|
451
|
+
// ---------------------------------------------------------------------------
|
|
452
|
+
|
|
453
|
+
describe('findOptionByName', () => {
|
|
454
|
+
const field = {
|
|
455
|
+
id: 'PVTSSF_status',
|
|
456
|
+
name: 'Status',
|
|
457
|
+
type: 'single_select',
|
|
458
|
+
options: [
|
|
459
|
+
{ id: 'opt_todo', name: 'Todo' },
|
|
460
|
+
{ id: 'opt_inprogress', name: 'In Progress' },
|
|
461
|
+
{ id: 'opt_done', name: 'Done' },
|
|
462
|
+
],
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
it('returns correct option by name', () => {
|
|
466
|
+
const result = findOptionByName(field, 'In Progress');
|
|
467
|
+
expect(result).toBeDefined();
|
|
468
|
+
expect(result.id).toBe('opt_inprogress');
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it('returns null for missing option', () => {
|
|
472
|
+
const result = findOptionByName(field, 'Cancelled');
|
|
473
|
+
expect(result).toBeNull();
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it('is case-insensitive', () => {
|
|
477
|
+
const result = findOptionByName(field, 'done');
|
|
478
|
+
expect(result).toBeDefined();
|
|
479
|
+
expect(result.id).toBe('opt_done');
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it('returns null for field without options', () => {
|
|
483
|
+
const plainField = { id: 'PVTF_title', name: 'Title', type: 'field' };
|
|
484
|
+
const result = findOptionByName(plainField, 'anything');
|
|
485
|
+
expect(result).toBeNull();
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
// ---------------------------------------------------------------------------
|
|
490
|
+
// createProjectsClient (factory with caching)
|
|
491
|
+
// ---------------------------------------------------------------------------
|
|
492
|
+
|
|
493
|
+
describe('createProjectsClient', () => {
|
|
494
|
+
it('caches schema after first call', () => {
|
|
495
|
+
const exec = vi.fn()
|
|
496
|
+
.mockReturnValueOnce(Buffer.from(makeOrgDiscoveryResponse()))
|
|
497
|
+
.mockReturnValueOnce(Buffer.from(makeAddItemResponse()));
|
|
498
|
+
|
|
499
|
+
const client = createProjectsClient({ org: 'KashaTech', projectTitle: 'TLC Board', exec });
|
|
500
|
+
|
|
501
|
+
// First call triggers discovery
|
|
502
|
+
const addResult = client.addItem({ contentId: 'I_issue1' });
|
|
503
|
+
expect(addResult.itemId).toBe('PVTI_item1');
|
|
504
|
+
|
|
505
|
+
// exec should have been called twice: once for discover, once for addItem
|
|
506
|
+
expect(exec).toHaveBeenCalledTimes(2);
|
|
507
|
+
// First call should be the discovery query
|
|
508
|
+
expect(exec.mock.calls[0][0]).toContain('projectsV2');
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it('re-uses cached fields on subsequent calls', () => {
|
|
512
|
+
const exec = vi.fn()
|
|
513
|
+
.mockReturnValueOnce(Buffer.from(makeOrgDiscoveryResponse()))
|
|
514
|
+
.mockReturnValueOnce(Buffer.from(makeAddItemResponse('PVTI_a')))
|
|
515
|
+
.mockReturnValueOnce(Buffer.from(makeSetFieldResponse()))
|
|
516
|
+
.mockReturnValueOnce(Buffer.from(makeAddItemResponse('PVTI_b')));
|
|
517
|
+
|
|
518
|
+
const client = createProjectsClient({ org: 'KashaTech', projectTitle: 'TLC Board', exec });
|
|
519
|
+
|
|
520
|
+
// First call — triggers discovery + addItem
|
|
521
|
+
client.addItem({ contentId: 'I_1' });
|
|
522
|
+
// Second call — setFieldValue, no re-discovery
|
|
523
|
+
client.setField({ itemId: 'PVTI_a', fieldId: 'PVTSSF_status', optionId: 'opt_done' });
|
|
524
|
+
// Third call — another addItem, still no re-discovery
|
|
525
|
+
client.addItem({ contentId: 'I_2' });
|
|
526
|
+
|
|
527
|
+
// Discovery should only happen once (first call in exec.mock.calls)
|
|
528
|
+
const discoveryCalls = exec.mock.calls.filter(c => c[0].includes('projectsV2'));
|
|
529
|
+
expect(discoveryCalls).toHaveLength(1);
|
|
530
|
+
|
|
531
|
+
// Total exec calls: 1 discover + 3 operations
|
|
532
|
+
expect(exec).toHaveBeenCalledTimes(4);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it('exposes project info after discovery', () => {
|
|
536
|
+
const exec = vi.fn()
|
|
537
|
+
.mockReturnValueOnce(Buffer.from(makeOrgDiscoveryResponse()));
|
|
538
|
+
|
|
539
|
+
const client = createProjectsClient({ org: 'KashaTech', projectTitle: 'TLC Board', exec });
|
|
540
|
+
const info = client.getProjectInfo();
|
|
541
|
+
|
|
542
|
+
expect(info.projectId).toBe('PVT_abc123');
|
|
543
|
+
expect(info.title).toBe('TLC Board');
|
|
544
|
+
expect(info.fields).toBeInstanceOf(Array);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it('returns error if project discovery fails', () => {
|
|
548
|
+
const exec = makeExecThrowing('command not found: gh');
|
|
549
|
+
const client = createProjectsClient({ org: 'KashaTech', projectTitle: 'TLC Board', exec });
|
|
550
|
+
|
|
551
|
+
const info = client.getProjectInfo();
|
|
552
|
+
expect(info.error).toBeDefined();
|
|
553
|
+
expect(info.code).toBe('GH_NOT_FOUND');
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
it('provides findField and findOption helpers bound to cached schema', () => {
|
|
557
|
+
const exec = vi.fn()
|
|
558
|
+
.mockReturnValueOnce(Buffer.from(makeOrgDiscoveryResponse()));
|
|
559
|
+
|
|
560
|
+
const client = createProjectsClient({ org: 'KashaTech', projectTitle: 'TLC Board', exec });
|
|
561
|
+
|
|
562
|
+
const statusField = client.findField('Status');
|
|
563
|
+
expect(statusField).toBeDefined();
|
|
564
|
+
expect(statusField.id).toBe('PVTSSF_status');
|
|
565
|
+
|
|
566
|
+
const option = client.findOption(statusField, 'In Progress');
|
|
567
|
+
expect(option).toBeDefined();
|
|
568
|
+
expect(option.id).toBe('opt_inprogress');
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
it('works with user instead of org', () => {
|
|
572
|
+
const exec = vi.fn()
|
|
573
|
+
.mockReturnValueOnce(Buffer.from(makeUserDiscoveryResponse()));
|
|
574
|
+
|
|
575
|
+
const client = createProjectsClient({ user: 'octocat', projectTitle: 'TLC Board', exec });
|
|
576
|
+
const info = client.getProjectInfo();
|
|
577
|
+
|
|
578
|
+
expect(info.projectId).toBe('PVT_abc123');
|
|
579
|
+
const call = exec.mock.calls[0][0];
|
|
580
|
+
expect(call).toContain('user');
|
|
581
|
+
expect(call).toContain('octocat');
|
|
582
|
+
});
|
|
583
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub integration barrel export
|
|
3
|
+
* Phase 97
|
|
4
|
+
*
|
|
5
|
+
* Re-exports all functions from the github/ directory modules.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const ghClient = require('./gh-client');
|
|
9
|
+
const ghProjects = require('./gh-projects');
|
|
10
|
+
const config = require('./config');
|
|
11
|
+
|
|
12
|
+
const planSync = require('./plan-sync');
|
|
13
|
+
|
|
14
|
+
module.exports = {
|
|
15
|
+
...ghClient,
|
|
16
|
+
...ghProjects,
|
|
17
|
+
...planSync,
|
|
18
|
+
...config,
|
|
19
|
+
};
|