tlc-claude-code 2.4.10 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,594 @@
1
+ /**
2
+ * GitHub Projects V2 GraphQL Client
3
+ *
4
+ * Manages GitHub Projects V2 via GraphQL — discover project schema,
5
+ * add items, set field values.
6
+ *
7
+ * All functions accept `{ exec }` for dependency injection (defaults to
8
+ * child_process.execSync). GraphQL calls go through `gh api graphql`.
9
+ *
10
+ * Error handling: structured { error, code } returns, never throws.
11
+ * Every catch block logs with context.
12
+ *
13
+ * @module gh-projects
14
+ */
15
+
16
+ const { execSync } = require('child_process');
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Error codes
20
+ // ---------------------------------------------------------------------------
21
+
22
+ const ERROR_CODES = {
23
+ GH_NOT_FOUND: 'GH_NOT_FOUND',
24
+ GH_AUTH_REQUIRED: 'GH_AUTH_REQUIRED',
25
+ GH_SCOPE_MISSING: 'GH_SCOPE_MISSING',
26
+ GH_PROJECT_NOT_FOUND: 'GH_PROJECT_NOT_FOUND',
27
+ GH_PERMISSION_DENIED: 'GH_PERMISSION_DENIED',
28
+ GH_API_ERROR: 'GH_API_ERROR',
29
+ };
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Error classification
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /**
36
+ * Classify an exec error into a structured error response.
37
+ * @param {Error} err - Error thrown by exec
38
+ * @param {string} context - Description of what was being attempted
39
+ * @returns {{ error: string, code: string }}
40
+ */
41
+ function classifyError(err, context) {
42
+ const stderr = err.stderr ? err.stderr.toString() : '';
43
+ const message = err.message || '';
44
+ const combined = `${stderr} ${message}`.toLowerCase();
45
+
46
+ if (combined.includes('command not found') || combined.includes('not found: gh') || combined.includes('enoent')) {
47
+ const errorMsg = `gh CLI not found. Install from https://cli.github.com/ (context: ${context})`;
48
+ console.error(`[gh-projects] ${errorMsg}`);
49
+ return { error: errorMsg, code: ERROR_CODES.GH_NOT_FOUND };
50
+ }
51
+
52
+ if (combined.includes('gh auth login') || combined.includes('not logged in') || combined.includes('authentication')) {
53
+ const errorMsg = `gh CLI not authenticated. Run: gh auth login (context: ${context})`;
54
+ console.error(`[gh-projects] ${errorMsg}`);
55
+ return { error: errorMsg, code: ERROR_CODES.GH_AUTH_REQUIRED };
56
+ }
57
+
58
+ if (combined.includes('scope') && combined.includes('project')) {
59
+ const errorMsg = `Missing 'project' scope. Run: gh auth refresh -s project (context: ${context})`;
60
+ console.error(`[gh-projects] ${errorMsg}`);
61
+ return { error: errorMsg, code: ERROR_CODES.GH_SCOPE_MISSING };
62
+ }
63
+
64
+ if (combined.includes('not accessible') || combined.includes('permission') || combined.includes('forbidden')) {
65
+ const errorMsg = `Permission denied: ${stderr.trim() || message} (context: ${context})`;
66
+ console.error(`[gh-projects] ${errorMsg}`);
67
+ return { error: errorMsg, code: ERROR_CODES.GH_PERMISSION_DENIED };
68
+ }
69
+
70
+ const errorMsg = `GitHub API error: ${stderr.trim() || message} (context: ${context})`;
71
+ console.error(`[gh-projects] ${errorMsg}`);
72
+ return { error: errorMsg, code: ERROR_CODES.GH_API_ERROR };
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // GraphQL execution helper
77
+ // ---------------------------------------------------------------------------
78
+
79
+ /**
80
+ * Execute a GraphQL query/mutation via `gh api graphql`.
81
+ * @param {string} query - GraphQL query string
82
+ * @param {Function} exec - execSync-compatible function
83
+ * @returns {object} Parsed JSON response
84
+ * @throws {Error} On exec failure (caller must catch)
85
+ */
86
+ function runGraphQL(query, exec) {
87
+ // Escape single quotes in the query for shell safety
88
+ const escaped = query.replace(/'/g, "'\\''");
89
+ const cmd = `gh api graphql -f query='${escaped}'`;
90
+ const output = exec(cmd, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
91
+ return JSON.parse(output.toString());
92
+ }
93
+
94
+ /**
95
+ * Escape a string for safe embedding in a GraphQL string literal.
96
+ * Escapes double quotes, backslashes, and newlines.
97
+ * @param {string} str
98
+ * @returns {string}
99
+ */
100
+ function gqlEscape(str) {
101
+ if (str == null) return '';
102
+ return String(str).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Field extraction
107
+ // ---------------------------------------------------------------------------
108
+
109
+ /**
110
+ * Extract normalized field list from a project node.
111
+ * @param {object} projectNode - Raw project node from GraphQL
112
+ * @returns {Array<{ id: string, name: string, type: string, options?: Array<{ id: string, name: string }> }>}
113
+ */
114
+ function extractFields(projectNode) {
115
+ if (!projectNode.fields || !projectNode.fields.nodes) return [];
116
+
117
+ return projectNode.fields.nodes.map(node => {
118
+ const typename = node.__typename || '';
119
+ if (typename === 'ProjectV2SingleSelectField' || (node.options && Array.isArray(node.options))) {
120
+ return {
121
+ id: node.id,
122
+ name: node.name,
123
+ type: 'single_select',
124
+ options: (node.options || []).map(o => ({ id: o.id, name: o.name })),
125
+ };
126
+ }
127
+ return {
128
+ id: node.id,
129
+ name: node.name,
130
+ type: 'field',
131
+ };
132
+ });
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // discoverProject
137
+ // ---------------------------------------------------------------------------
138
+
139
+ /**
140
+ * Discover a GitHub Project V2 by title.
141
+ * Queries organization or user projects depending on which is provided.
142
+ *
143
+ * @param {object} params
144
+ * @param {string} [params.org] - Organization login (mutually exclusive with user)
145
+ * @param {string} [params.user] - User login (mutually exclusive with org)
146
+ * @param {string} params.projectTitle - Project title to find
147
+ * @param {Function} [params.exec] - execSync-compatible function (DI)
148
+ * @returns {{ projectId: string, title: string, number: number, fields: Array } | { error: string, code: string }}
149
+ */
150
+ function discoverProject({ org, user, projectTitle, exec = execSync }) {
151
+ const owner = org || user;
152
+ const ownerType = org ? 'organization' : 'user';
153
+ const context = `discoverProject(${ownerType}=${owner}, title="${projectTitle}")`;
154
+
155
+ const query = `
156
+ query {
157
+ ${ownerType}(login: "${gqlEscape(owner)}") {
158
+ projectsV2(first: 20) {
159
+ nodes {
160
+ id title number
161
+ fields(first: 30) {
162
+ nodes {
163
+ ... on ProjectV2SingleSelectField {
164
+ __typename id name options { id name }
165
+ }
166
+ ... on ProjectV2Field {
167
+ __typename id name
168
+ }
169
+ }
170
+ }
171
+ }
172
+ }
173
+ }
174
+ }
175
+ `;
176
+
177
+ let data;
178
+ try {
179
+ data = runGraphQL(query, exec);
180
+ } catch (err) {
181
+ return classifyError(err, context);
182
+ }
183
+
184
+ // Navigate to the projects list
185
+ const ownerData = data.data[ownerType];
186
+ if (!ownerData || !ownerData.projectsV2 || !ownerData.projectsV2.nodes) {
187
+ console.error(`[gh-projects] No projects found for ${ownerType}=${owner}`);
188
+ return {
189
+ error: `No projects found for ${ownerType} "${owner}"`,
190
+ code: ERROR_CODES.GH_PROJECT_NOT_FOUND,
191
+ };
192
+ }
193
+
194
+ const nodes = ownerData.projectsV2.nodes;
195
+ const project = nodes.find(p => p.title === projectTitle);
196
+
197
+ if (!project) {
198
+ const available = nodes.map(p => p.title).join(', ') || 'none';
199
+ const errorMsg = `Project "${projectTitle}" not found for ${ownerType} "${owner}". Available: ${available}`;
200
+ console.error(`[gh-projects] ${errorMsg}`);
201
+ return { error: errorMsg, code: ERROR_CODES.GH_PROJECT_NOT_FOUND };
202
+ }
203
+
204
+ return {
205
+ projectId: project.id,
206
+ title: project.title,
207
+ number: project.number,
208
+ fields: extractFields(project),
209
+ };
210
+ }
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // addItemToProject
214
+ // ---------------------------------------------------------------------------
215
+
216
+ /**
217
+ * Add an issue or PR to a Project V2.
218
+ *
219
+ * @param {object} params
220
+ * @param {string} params.projectId - Project node ID (PVT_xxx)
221
+ * @param {string} params.contentId - Issue or PR node ID (I_xxx or PR_xxx)
222
+ * @param {Function} [params.exec] - execSync-compatible function (DI)
223
+ * @returns {{ itemId: string } | { error: string, code: string }}
224
+ */
225
+ function addItemToProject({ projectId, contentId, exec = execSync }) {
226
+ const context = `addItemToProject(project=${projectId}, content=${contentId})`;
227
+
228
+ const query = `
229
+ mutation {
230
+ addProjectV2ItemById(input: { projectId: "${gqlEscape(projectId)}", contentId: "${gqlEscape(contentId)}" }) {
231
+ item { id }
232
+ }
233
+ }
234
+ `;
235
+
236
+ let data;
237
+ try {
238
+ data = runGraphQL(query, exec);
239
+ } catch (err) {
240
+ return classifyError(err, context);
241
+ }
242
+
243
+ try {
244
+ const itemId = data.data.addProjectV2ItemById.item.id;
245
+ return { itemId };
246
+ } catch (parseErr) {
247
+ const errorMsg = `Failed to parse addItem response: ${JSON.stringify(data)} (context: ${context})`;
248
+ console.error(`[gh-projects] ${errorMsg}`);
249
+ return { error: errorMsg, code: ERROR_CODES.GH_API_ERROR };
250
+ }
251
+ }
252
+
253
+ // ---------------------------------------------------------------------------
254
+ // setFieldValue
255
+ // ---------------------------------------------------------------------------
256
+
257
+ /**
258
+ * Set a single-select field value on a project item.
259
+ *
260
+ * @param {object} params
261
+ * @param {string} params.projectId - Project node ID
262
+ * @param {string} params.itemId - Item node ID (PVTI_xxx)
263
+ * @param {string} params.fieldId - Field node ID (PVTSSF_xxx)
264
+ * @param {string} params.optionId - Option ID for the single-select value
265
+ * @param {Function} [params.exec] - execSync-compatible function (DI)
266
+ * @returns {{ success: true } | { error: string, code: string }}
267
+ */
268
+ function setFieldValue({ projectId, itemId, fieldId, optionId, exec = execSync }) {
269
+ const context = `setFieldValue(project=${projectId}, item=${itemId}, field=${fieldId}, option=${optionId})`;
270
+
271
+ const query = `
272
+ mutation {
273
+ updateProjectV2ItemFieldValue(input: {
274
+ projectId: "${gqlEscape(projectId)}"
275
+ itemId: "${gqlEscape(itemId)}"
276
+ fieldId: "${gqlEscape(fieldId)}"
277
+ value: { singleSelectOptionId: "${gqlEscape(optionId)}" }
278
+ }) {
279
+ projectV2Item { id }
280
+ }
281
+ }
282
+ `;
283
+
284
+ try {
285
+ runGraphQL(query, exec);
286
+ return { success: true };
287
+ } catch (err) {
288
+ return classifyError(err, context);
289
+ }
290
+ }
291
+
292
+ // ---------------------------------------------------------------------------
293
+ // createFieldOption
294
+ // ---------------------------------------------------------------------------
295
+
296
+ /**
297
+ * Create a new option on a single-select field (e.g., add a Sprint option).
298
+ * Requires admin permission on the project.
299
+ *
300
+ * @param {object} params
301
+ * @param {string} params.projectId - Project node ID
302
+ * @param {string} params.fieldId - Field node ID (PVTSSF_xxx)
303
+ * @param {string} params.name - Option name to create
304
+ * @param {Function} [params.exec] - execSync-compatible function (DI)
305
+ * @returns {{ optionId: string } | { error: string, code: string }}
306
+ */
307
+ function createFieldOption({ projectId, fieldId, name, exec = execSync }) {
308
+ const context = `createFieldOption(project=${projectId}, field=${fieldId}, name="${name}")`;
309
+
310
+ const query = `
311
+ mutation {
312
+ createProjectV2FieldOption(input: {
313
+ projectId: "${gqlEscape(projectId)}"
314
+ fieldId: "${gqlEscape(fieldId)}"
315
+ name: "${gqlEscape(name)}"
316
+ }) {
317
+ projectV2Field {
318
+ ... on ProjectV2SingleSelectField {
319
+ options { id name }
320
+ }
321
+ }
322
+ }
323
+ }
324
+ `;
325
+
326
+ let data;
327
+ try {
328
+ data = runGraphQL(query, exec);
329
+ } catch (err) {
330
+ return classifyError(err, context);
331
+ }
332
+
333
+ try {
334
+ const options = data.data.createProjectV2FieldOption.projectV2Field.options;
335
+ const created = options.find(o => o.name === name);
336
+ if (!created) {
337
+ const errorMsg = `Option "${name}" was not found in response after creation (context: ${context})`;
338
+ console.error(`[gh-projects] ${errorMsg}`);
339
+ return { error: errorMsg, code: ERROR_CODES.GH_API_ERROR };
340
+ }
341
+ return { optionId: created.id };
342
+ } catch (parseErr) {
343
+ const errorMsg = `Failed to parse createFieldOption response: ${JSON.stringify(data)} (context: ${context})`;
344
+ console.error(`[gh-projects] ${errorMsg}`);
345
+ return { error: errorMsg, code: ERROR_CODES.GH_API_ERROR };
346
+ }
347
+ }
348
+
349
+ // ---------------------------------------------------------------------------
350
+ // getProjectItems
351
+ // ---------------------------------------------------------------------------
352
+
353
+ /**
354
+ * Fetch items from a Project V2 with their field values.
355
+ *
356
+ * @param {object} params
357
+ * @param {string} params.projectId - Project node ID
358
+ * @param {number} [params.first=50] - Number of items to fetch
359
+ * @param {Function} [params.exec] - execSync-compatible function (DI)
360
+ * @returns {Array<{ itemId, contentId, contentType, title, fieldValues }> | { error: string, code: string }}
361
+ */
362
+ function getProjectItems({ projectId, first = 50, exec = execSync }) {
363
+ const context = `getProjectItems(project=${projectId}, first=${first})`;
364
+
365
+ const query = `
366
+ query {
367
+ node(id: "${gqlEscape(projectId)}") {
368
+ ... on ProjectV2 {
369
+ items(first: ${Number(first) || 50}) {
370
+ nodes {
371
+ id
372
+ content {
373
+ ... on Issue { __typename id title }
374
+ ... on PullRequest { __typename id title }
375
+ ... on DraftIssue { __typename id title }
376
+ }
377
+ fieldValues(first: 20) {
378
+ nodes {
379
+ ... on ProjectV2ItemFieldTextValue { __typename text field { name } }
380
+ ... on ProjectV2ItemFieldSingleSelectValue { __typename name field { name } }
381
+ ... on ProjectV2ItemFieldNumberValue { __typename number field { name } }
382
+ ... on ProjectV2ItemFieldDateValue { __typename date field { name } }
383
+ }
384
+ }
385
+ }
386
+ }
387
+ }
388
+ }
389
+ }
390
+ `;
391
+
392
+ let data;
393
+ try {
394
+ data = runGraphQL(query, exec);
395
+ } catch (err) {
396
+ return classifyError(err, context);
397
+ }
398
+
399
+ try {
400
+ const items = data.data.node.items.nodes;
401
+ return items.map(item => {
402
+ const content = item.content || {};
403
+ const fieldValues = {};
404
+
405
+ if (item.fieldValues && item.fieldValues.nodes) {
406
+ for (const fv of item.fieldValues.nodes) {
407
+ if (!fv || !fv.field) continue;
408
+ const fieldName = fv.field.name;
409
+ const typename = fv.__typename || '';
410
+
411
+ if (typename === 'ProjectV2ItemFieldSingleSelectValue') {
412
+ fieldValues[fieldName] = fv.name;
413
+ } else if (typename === 'ProjectV2ItemFieldTextValue') {
414
+ fieldValues[fieldName] = fv.text;
415
+ } else if (typename === 'ProjectV2ItemFieldNumberValue') {
416
+ fieldValues[fieldName] = fv.number;
417
+ } else if (typename === 'ProjectV2ItemFieldDateValue') {
418
+ fieldValues[fieldName] = fv.date;
419
+ }
420
+ }
421
+ }
422
+
423
+ return {
424
+ itemId: item.id,
425
+ contentId: content.id || null,
426
+ contentType: content.__typename || null,
427
+ title: content.title || null,
428
+ fieldValues,
429
+ };
430
+ });
431
+ } catch (parseErr) {
432
+ const errorMsg = `Failed to parse getProjectItems response: ${parseErr.message} (context: ${context})`;
433
+ console.error(`[gh-projects] ${errorMsg}`);
434
+ return { error: errorMsg, code: ERROR_CODES.GH_API_ERROR };
435
+ }
436
+ }
437
+
438
+ // ---------------------------------------------------------------------------
439
+ // Pure helpers
440
+ // ---------------------------------------------------------------------------
441
+
442
+ /**
443
+ * Find a field by name in the fields array (case-insensitive).
444
+ *
445
+ * @param {Array<{ id, name, type, options? }>} fields - Fields array from discoverProject
446
+ * @param {string} name - Field name to find
447
+ * @returns {{ id, name, type, options? } | null}
448
+ */
449
+ function findFieldByName(fields, name) {
450
+ if (!fields || !name) return null;
451
+ const lower = name.toLowerCase();
452
+ return fields.find(f => f.name.toLowerCase() === lower) || null;
453
+ }
454
+
455
+ /**
456
+ * Find an option by name in a field (case-insensitive).
457
+ *
458
+ * @param {{ options?: Array<{ id, name }> }} field - Field object with options
459
+ * @param {string} optionName - Option name to find
460
+ * @returns {{ id, name } | null}
461
+ */
462
+ function findOptionByName(field, optionName) {
463
+ if (!field || !field.options || !optionName) return null;
464
+ const lower = optionName.toLowerCase();
465
+ return field.options.find(o => o.name.toLowerCase() === lower) || null;
466
+ }
467
+
468
+ // ---------------------------------------------------------------------------
469
+ // createProjectsClient (factory with caching)
470
+ // ---------------------------------------------------------------------------
471
+
472
+ /**
473
+ * Create a Projects V2 client that discovers the project once and caches
474
+ * the schema for the lifetime of the client.
475
+ *
476
+ * @param {object} params
477
+ * @param {string} [params.org] - Organization login
478
+ * @param {string} [params.user] - User login
479
+ * @param {string} params.projectTitle - Project title
480
+ * @param {Function} [params.exec] - execSync-compatible function (DI)
481
+ * @returns {object} Client with addItem, setField, createOption, getItems, findField, findOption, getProjectInfo
482
+ */
483
+ function createProjectsClient({ org, user, projectTitle, exec = execSync }) {
484
+ let cached = null;
485
+ let discoveryError = null;
486
+
487
+ function ensureDiscovered() {
488
+ if (cached) return cached;
489
+ if (discoveryError) return null;
490
+
491
+ const result = discoverProject({ org, user, projectTitle, exec });
492
+ if (result.error) {
493
+ discoveryError = result;
494
+ console.error(`[gh-projects] Client discovery failed: ${result.error}`);
495
+ return null;
496
+ }
497
+
498
+ cached = result;
499
+ return cached;
500
+ }
501
+
502
+ return {
503
+ /**
504
+ * Get discovered project info (triggers discovery if needed).
505
+ * @returns {{ projectId, title, number, fields } | { error, code }}
506
+ */
507
+ getProjectInfo() {
508
+ const project = ensureDiscovered();
509
+ if (!project) return discoveryError;
510
+ return { projectId: project.projectId, title: project.title, number: project.number, fields: project.fields };
511
+ },
512
+
513
+ /**
514
+ * Add an issue/PR to the project.
515
+ * @param {{ contentId: string }} params
516
+ * @returns {{ itemId } | { error, code }}
517
+ */
518
+ addItem({ contentId }) {
519
+ const project = ensureDiscovered();
520
+ if (!project) return discoveryError;
521
+ return addItemToProject({ projectId: project.projectId, contentId, exec });
522
+ },
523
+
524
+ /**
525
+ * Set a single-select field value on a project item.
526
+ * @param {{ itemId: string, fieldId: string, optionId: string }} params
527
+ * @returns {{ success: true } | { error, code }}
528
+ */
529
+ setField({ itemId, fieldId, optionId }) {
530
+ const project = ensureDiscovered();
531
+ if (!project) return discoveryError;
532
+ return setFieldValue({ projectId: project.projectId, itemId, fieldId, optionId, exec });
533
+ },
534
+
535
+ /**
536
+ * Create a new option on a single-select field.
537
+ * @param {{ fieldId: string, name: string }} params
538
+ * @returns {{ optionId } | { error, code }}
539
+ */
540
+ createOption({ fieldId, name }) {
541
+ const project = ensureDiscovered();
542
+ if (!project) return discoveryError;
543
+ return createFieldOption({ projectId: project.projectId, fieldId, name, exec });
544
+ },
545
+
546
+ /**
547
+ * Get project items with field values.
548
+ * @param {{ first?: number }} [params]
549
+ * @returns {Array | { error, code }}
550
+ */
551
+ getItems({ first } = {}) {
552
+ const project = ensureDiscovered();
553
+ if (!project) return discoveryError;
554
+ return getProjectItems({ projectId: project.projectId, first, exec });
555
+ },
556
+
557
+ /**
558
+ * Find a field by name (case-insensitive) in cached schema.
559
+ * @param {string} name
560
+ * @returns {{ id, name, type, options? } | null}
561
+ */
562
+ findField(name) {
563
+ const project = ensureDiscovered();
564
+ if (!project) return null;
565
+ return findFieldByName(project.fields, name);
566
+ },
567
+
568
+ /**
569
+ * Find an option by name in a field (case-insensitive).
570
+ * @param {{ options?: Array }} field
571
+ * @param {string} optionName
572
+ * @returns {{ id, name } | null}
573
+ */
574
+ findOption(field, optionName) {
575
+ return findOptionByName(field, optionName);
576
+ },
577
+ };
578
+ }
579
+
580
+ // ---------------------------------------------------------------------------
581
+ // Exports
582
+ // ---------------------------------------------------------------------------
583
+
584
+ module.exports = {
585
+ discoverProject,
586
+ addItemToProject,
587
+ setFieldValue,
588
+ createFieldOption,
589
+ getProjectItems,
590
+ findFieldByName,
591
+ findOptionByName,
592
+ createProjectsClient,
593
+ ERROR_CODES,
594
+ };