tlc-claude-code 2.4.9 → 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/audit.md +5 -0
- package/.claude/commands/tlc/build.md +145 -44
- package/.claude/commands/tlc/issues.md +46 -0
- package/.claude/commands/tlc/plan.md +43 -0
- package/.claude/commands/tlc/review.md +596 -571
- 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,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
|
+
};
|