@kaitranntt/ccs 7.61.1-dev.1 → 7.61.1
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/package.json
CHANGED
|
@@ -1,379 +0,0 @@
|
|
|
1
|
-
const REQUIRED_PROJECT_FIELDS = ['Status', 'Priority', 'Follow-up', 'Next review'];
|
|
2
|
-
const DEFAULT_REPO_FULL_NAME = 'kaitranntt/ccs';
|
|
3
|
-
const DEFAULT_CLOSED_LOOKBACK_DAYS = 14;
|
|
4
|
-
const PRIORITY_FOR = { bug: 'P1', default: 'P2', split: 'P3' };
|
|
5
|
-
const FOLLOW_UP_FOR = {
|
|
6
|
-
ready: 'Ready',
|
|
7
|
-
repro: 'Needs repro',
|
|
8
|
-
upstream: 'Blocked upstream',
|
|
9
|
-
split: 'Needs split',
|
|
10
|
-
docs: 'Docs follow-up',
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
const PROJECT_QUERY = `query($owner: String!, $number: Int!, $itemCursor: String) {
|
|
14
|
-
user(login: $owner) {
|
|
15
|
-
projectV2(number: $number) {
|
|
16
|
-
id
|
|
17
|
-
fields(first: 50) { nodes { __typename ... on ProjectV2Field { id name } ... on ProjectV2SingleSelectField { id name options { id name } } } }
|
|
18
|
-
items(first: 100, after: $itemCursor) {
|
|
19
|
-
pageInfo { hasNextPage endCursor }
|
|
20
|
-
nodes { id content { __typename ... on Issue { number id repository { nameWithOwner } } } }
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
}`;
|
|
25
|
-
const ADD_ITEM_MUTATION = `mutation($projectId: ID!, $contentId: ID!) {
|
|
26
|
-
addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) { item { id } }
|
|
27
|
-
}`;
|
|
28
|
-
const SET_SINGLE_SELECT_MUTATION = `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
|
|
29
|
-
updateProjectV2ItemFieldValue(input: {
|
|
30
|
-
projectId: $projectId, itemId: $itemId, fieldId: $fieldId, value: { singleSelectOptionId: $optionId }
|
|
31
|
-
}) { projectV2Item { id } }
|
|
32
|
-
}`;
|
|
33
|
-
const SET_DATE_MUTATION = `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $date: Date!) {
|
|
34
|
-
updateProjectV2ItemFieldValue(input: {
|
|
35
|
-
projectId: $projectId, itemId: $itemId, fieldId: $fieldId, value: { date: $date }
|
|
36
|
-
}) { projectV2Item { id } }
|
|
37
|
-
}`;
|
|
38
|
-
const CLEAR_FIELD_MUTATION = `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!) {
|
|
39
|
-
clearProjectV2ItemFieldValue(input: {projectId: $projectId, itemId: $itemId, fieldId: $fieldId}) { projectV2Item { id } }
|
|
40
|
-
}`;
|
|
41
|
-
|
|
42
|
-
export function isoDate(daysFromNow, now = new Date()) {
|
|
43
|
-
const date = new Date(now);
|
|
44
|
-
date.setUTCDate(date.getUTCDate() + daysFromNow);
|
|
45
|
-
return date.toISOString().slice(0, 10);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function classify(labels, state, now = new Date()) {
|
|
49
|
-
const names = new Set(labels.map((label) => label.name));
|
|
50
|
-
const priority = names.has('bug')
|
|
51
|
-
? PRIORITY_FOR.bug
|
|
52
|
-
: names.has('needs-split')
|
|
53
|
-
? PRIORITY_FOR.split
|
|
54
|
-
: PRIORITY_FOR.default;
|
|
55
|
-
if (state === 'closed')
|
|
56
|
-
return { priority, followUp: FOLLOW_UP_FOR.ready, nextReview: null, status: 'Done' };
|
|
57
|
-
if (names.has('upstream-blocked'))
|
|
58
|
-
return {
|
|
59
|
-
priority,
|
|
60
|
-
followUp: FOLLOW_UP_FOR.upstream,
|
|
61
|
-
nextReview: isoDate(7, now),
|
|
62
|
-
status: 'Todo',
|
|
63
|
-
};
|
|
64
|
-
if (names.has('needs-repro'))
|
|
65
|
-
return {
|
|
66
|
-
priority,
|
|
67
|
-
followUp: FOLLOW_UP_FOR.repro,
|
|
68
|
-
nextReview: isoDate(14, now),
|
|
69
|
-
status: 'Todo',
|
|
70
|
-
};
|
|
71
|
-
if (names.has('needs-split'))
|
|
72
|
-
return {
|
|
73
|
-
priority,
|
|
74
|
-
followUp: FOLLOW_UP_FOR.split,
|
|
75
|
-
nextReview: isoDate(14, now),
|
|
76
|
-
status: 'Todo',
|
|
77
|
-
};
|
|
78
|
-
if (names.has('docs-gap'))
|
|
79
|
-
return { priority, followUp: FOLLOW_UP_FOR.docs, nextReview: isoDate(7, now), status: 'Todo' };
|
|
80
|
-
return { priority, followUp: FOLLOW_UP_FOR.ready, nextReview: null, status: 'Todo' };
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export function parseRepoFullName(repoFullName = DEFAULT_REPO_FULL_NAME) {
|
|
84
|
-
const [repoOwner, repoName, extra] = String(repoFullName).split('/');
|
|
85
|
-
if (!repoOwner || !repoName || extra) {
|
|
86
|
-
throw new Error(`Invalid GITHUB_REPOSITORY value "${repoFullName}". Expected OWNER/REPO.`);
|
|
87
|
-
}
|
|
88
|
-
return { repoOwner, repoName, repoFullName: `${repoOwner}/${repoName}` };
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
export function parseNextLink(linkHeader) {
|
|
92
|
-
if (!linkHeader) return null;
|
|
93
|
-
for (const segment of linkHeader.split(',')) {
|
|
94
|
-
const match = segment.match(/<([^>]+)>\s*;\s*rel="([^"]+)"/);
|
|
95
|
-
if (match?.[2] === 'next') return match[1];
|
|
96
|
-
}
|
|
97
|
-
return null;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function getHeader(headers, name) {
|
|
101
|
-
if (typeof headers?.get === 'function') return headers.get(name);
|
|
102
|
-
return headers?.[name] || headers?.[name.toLowerCase()] || null;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function buildCutoffTimestamp(now, days) {
|
|
106
|
-
const cutoff = new Date(now);
|
|
107
|
-
cutoff.setUTCDate(cutoff.getUTCDate() - days);
|
|
108
|
-
return cutoff.toISOString();
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function isRecentlyClosed(issue, now, days) {
|
|
112
|
-
if (issue.state !== 'closed' || !issue.closed_at) return false;
|
|
113
|
-
return Date.parse(issue.closed_at) >= Date.parse(buildCutoffTimestamp(now, days));
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export function validateProjectFields(fields) {
|
|
117
|
-
const missing = REQUIRED_PROJECT_FIELDS.filter((name) => !fields.has(name));
|
|
118
|
-
if (missing.length > 0) {
|
|
119
|
-
throw new Error(
|
|
120
|
-
`Missing required project field${missing.length > 1 ? 's' : ''}: ${missing.map((name) => `"${name}"`).join(', ')}`
|
|
121
|
-
);
|
|
122
|
-
}
|
|
123
|
-
return {
|
|
124
|
-
statusField: fields.get('Status'),
|
|
125
|
-
priorityField: fields.get('Priority'),
|
|
126
|
-
followUpField: fields.get('Follow-up'),
|
|
127
|
-
nextReviewField: fields.get('Next review'),
|
|
128
|
-
};
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
export async function listGithubCollection(initialPath, githubRequest) {
|
|
132
|
-
const items = [];
|
|
133
|
-
let nextPath = initialPath;
|
|
134
|
-
while (nextPath) {
|
|
135
|
-
const { body, headers } = await githubRequest(nextPath);
|
|
136
|
-
if (!Array.isArray(body)) throw new Error(`Expected array response for ${nextPath}`);
|
|
137
|
-
items.push(...body);
|
|
138
|
-
nextPath = parseNextLink(getHeader(headers, 'link'));
|
|
139
|
-
}
|
|
140
|
-
return items;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
export async function getProjectContext({ owner, projectNumber, repoFullName, graphqlRequest }) {
|
|
144
|
-
const fields = new Map();
|
|
145
|
-
const itemsByNumber = new Map();
|
|
146
|
-
let projectId = null;
|
|
147
|
-
let itemCursor = null;
|
|
148
|
-
|
|
149
|
-
do {
|
|
150
|
-
const data = await graphqlRequest(PROJECT_QUERY, { owner, number: projectNumber, itemCursor });
|
|
151
|
-
const project = data.user?.projectV2;
|
|
152
|
-
if (!project) throw new Error(`Project ${owner}/${projectNumber} not found`);
|
|
153
|
-
projectId = projectId || project.id;
|
|
154
|
-
|
|
155
|
-
if (fields.size === 0) {
|
|
156
|
-
for (const node of project.fields.nodes) {
|
|
157
|
-
if (!node?.name) continue;
|
|
158
|
-
fields.set(node.name, {
|
|
159
|
-
id: node.id,
|
|
160
|
-
options: new Map((node.options || []).map((opt) => [opt.name, opt.id])),
|
|
161
|
-
});
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
for (const node of project.items.nodes) {
|
|
166
|
-
if (
|
|
167
|
-
node?.content?.__typename === 'Issue' &&
|
|
168
|
-
node.content.repository.nameWithOwner === repoFullName
|
|
169
|
-
) {
|
|
170
|
-
itemsByNumber.set(node.content.number, node.id);
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
itemCursor = project.items.pageInfo.hasNextPage ? project.items.pageInfo.endCursor : null;
|
|
175
|
-
} while (itemCursor);
|
|
176
|
-
|
|
177
|
-
return { projectId, itemsByNumber, ...validateProjectFields(fields) };
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
export async function listIssuesForSync({
|
|
181
|
-
repoOwner,
|
|
182
|
-
repoName,
|
|
183
|
-
githubRequest,
|
|
184
|
-
eventPath,
|
|
185
|
-
now = new Date(),
|
|
186
|
-
closedLookbackDays = DEFAULT_CLOSED_LOOKBACK_DAYS,
|
|
187
|
-
}) {
|
|
188
|
-
if (eventPath) {
|
|
189
|
-
const event = JSON.parse(
|
|
190
|
-
await import('node:fs/promises').then((fs) => fs.readFile(eventPath, 'utf8'))
|
|
191
|
-
);
|
|
192
|
-
if (event.issue && !event.issue.pull_request) return [event.issue];
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
const openIssues = await listGithubCollection(
|
|
196
|
-
`/repos/${repoOwner}/${repoName}/issues?state=open&per_page=100`,
|
|
197
|
-
githubRequest
|
|
198
|
-
);
|
|
199
|
-
const recentlyClosedIssues = await listGithubCollection(
|
|
200
|
-
`/repos/${repoOwner}/${repoName}/issues?state=closed&per_page=100&since=${encodeURIComponent(buildCutoffTimestamp(now, closedLookbackDays))}`,
|
|
201
|
-
githubRequest
|
|
202
|
-
);
|
|
203
|
-
|
|
204
|
-
const byNumber = new Map();
|
|
205
|
-
for (const issue of openIssues) {
|
|
206
|
-
if (!issue.pull_request) byNumber.set(issue.number, issue);
|
|
207
|
-
}
|
|
208
|
-
for (const issue of recentlyClosedIssues) {
|
|
209
|
-
if (!issue.pull_request && isRecentlyClosed(issue, now, closedLookbackDays))
|
|
210
|
-
byNumber.set(issue.number, issue);
|
|
211
|
-
}
|
|
212
|
-
return [...byNumber.values()];
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
async function ensureProjectItem(projectId, itemsByNumber, issue, graphqlRequest) {
|
|
216
|
-
const existing = itemsByNumber.get(issue.number);
|
|
217
|
-
if (existing) return existing;
|
|
218
|
-
if (!issue.node_id) throw new Error(`Issue #${issue.number} is missing node_id`);
|
|
219
|
-
const data = await graphqlRequest(ADD_ITEM_MUTATION, { projectId, contentId: issue.node_id });
|
|
220
|
-
const itemId = data.addProjectV2ItemById.item.id;
|
|
221
|
-
itemsByNumber.set(issue.number, itemId);
|
|
222
|
-
return itemId;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
async function setSingleSelect(projectId, itemId, field, optionName, graphqlRequest) {
|
|
226
|
-
const optionId = field.options.get(optionName);
|
|
227
|
-
if (!optionId) throw new Error(`Missing option "${optionName}" on field ${field.id}`);
|
|
228
|
-
await graphqlRequest(SET_SINGLE_SELECT_MUTATION, {
|
|
229
|
-
projectId,
|
|
230
|
-
itemId,
|
|
231
|
-
fieldId: field.id,
|
|
232
|
-
optionId,
|
|
233
|
-
});
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
async function setDate(projectId, itemId, fieldId, date, graphqlRequest) {
|
|
237
|
-
if (!date) {
|
|
238
|
-
await graphqlRequest(CLEAR_FIELD_MUTATION, { projectId, itemId, fieldId });
|
|
239
|
-
return;
|
|
240
|
-
}
|
|
241
|
-
await graphqlRequest(SET_DATE_MUTATION, { projectId, itemId, fieldId, date });
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
export async function syncIssues({
|
|
245
|
-
issues,
|
|
246
|
-
context,
|
|
247
|
-
graphqlRequest,
|
|
248
|
-
logger = console,
|
|
249
|
-
now = new Date(),
|
|
250
|
-
}) {
|
|
251
|
-
const failures = [];
|
|
252
|
-
for (const issue of issues) {
|
|
253
|
-
try {
|
|
254
|
-
if (issue.state === 'closed' && !context.itemsByNumber.has(issue.number)) {
|
|
255
|
-
logger.log(
|
|
256
|
-
`skipped #${issue.number}: closed issue is not currently tracked in the project`
|
|
257
|
-
);
|
|
258
|
-
continue;
|
|
259
|
-
}
|
|
260
|
-
const itemId = await ensureProjectItem(
|
|
261
|
-
context.projectId,
|
|
262
|
-
context.itemsByNumber,
|
|
263
|
-
issue,
|
|
264
|
-
graphqlRequest
|
|
265
|
-
);
|
|
266
|
-
const plan = classify(issue.labels || [], issue.state, now);
|
|
267
|
-
await setSingleSelect(
|
|
268
|
-
context.projectId,
|
|
269
|
-
itemId,
|
|
270
|
-
context.statusField,
|
|
271
|
-
plan.status,
|
|
272
|
-
graphqlRequest
|
|
273
|
-
);
|
|
274
|
-
await setSingleSelect(
|
|
275
|
-
context.projectId,
|
|
276
|
-
itemId,
|
|
277
|
-
context.priorityField,
|
|
278
|
-
plan.priority,
|
|
279
|
-
graphqlRequest
|
|
280
|
-
);
|
|
281
|
-
await setSingleSelect(
|
|
282
|
-
context.projectId,
|
|
283
|
-
itemId,
|
|
284
|
-
context.followUpField,
|
|
285
|
-
plan.followUp,
|
|
286
|
-
graphqlRequest
|
|
287
|
-
);
|
|
288
|
-
await setDate(
|
|
289
|
-
context.projectId,
|
|
290
|
-
itemId,
|
|
291
|
-
context.nextReviewField.id,
|
|
292
|
-
plan.nextReview,
|
|
293
|
-
graphqlRequest
|
|
294
|
-
);
|
|
295
|
-
logger.log(
|
|
296
|
-
`synced #${issue.number}: ${plan.status} / ${plan.priority} / ${plan.followUp}${plan.nextReview ? ` / ${plan.nextReview}` : ''}`
|
|
297
|
-
);
|
|
298
|
-
} catch (error) {
|
|
299
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
300
|
-
failures.push(`#${issue.number} (${detail})`);
|
|
301
|
-
logger.error(`[X] Failed to sync #${issue.number}: ${detail}`);
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
if (failures.length > 0)
|
|
305
|
-
throw new Error(`Failed to sync ${failures.length} issue(s): ${failures.join(', ')}`);
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
function formatGraphqlError(errors) {
|
|
309
|
-
const raw = JSON.stringify(errors);
|
|
310
|
-
if (/resource not accessible|insufficient|forbidden|project/i.test(raw)) {
|
|
311
|
-
return `GitHub Project access failed. Ensure GH_TOKEN or GITHUB_TOKEN has project scope and access to the target project. Raw: ${raw}`;
|
|
312
|
-
}
|
|
313
|
-
return `GitHub GraphQL failed: ${raw}`;
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
function buildRuntimeConfig(env = process.env) {
|
|
317
|
-
const token = env.GH_TOKEN || env.GITHUB_TOKEN;
|
|
318
|
-
if (!token) throw new Error('Missing GH_TOKEN or GITHUB_TOKEN');
|
|
319
|
-
const projectNumber = Number(env.CCS_PROJECT_NUMBER || '3');
|
|
320
|
-
if (!Number.isInteger(projectNumber) || projectNumber <= 0)
|
|
321
|
-
throw new Error('CCS_PROJECT_NUMBER must be a positive integer');
|
|
322
|
-
return {
|
|
323
|
-
token,
|
|
324
|
-
owner: env.CCS_PROJECT_OWNER || 'kaitranntt',
|
|
325
|
-
projectNumber,
|
|
326
|
-
eventPath: env.GITHUB_EVENT_PATH,
|
|
327
|
-
closedLookbackDays: Number(
|
|
328
|
-
env.CCS_PROJECT_RECENTLY_CLOSED_DAYS || String(DEFAULT_CLOSED_LOOKBACK_DAYS)
|
|
329
|
-
),
|
|
330
|
-
...parseRepoFullName(env.GITHUB_REPOSITORY || DEFAULT_REPO_FULL_NAME),
|
|
331
|
-
};
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
export async function runSync({ env = process.env, logger = console, fetchImpl = fetch } = {}) {
|
|
335
|
-
const config = buildRuntimeConfig(env);
|
|
336
|
-
const githubRequest = async (path, init = {}) => {
|
|
337
|
-
const response = await fetchImpl(
|
|
338
|
-
path.startsWith('http') ? path : `https://api.github.com${path}`,
|
|
339
|
-
{
|
|
340
|
-
...init,
|
|
341
|
-
headers: {
|
|
342
|
-
Accept: 'application/vnd.github+json',
|
|
343
|
-
Authorization: `Bearer ${config.token}`,
|
|
344
|
-
'X-GitHub-Api-Version': '2022-11-28',
|
|
345
|
-
...(init.headers || {}),
|
|
346
|
-
},
|
|
347
|
-
}
|
|
348
|
-
);
|
|
349
|
-
const body = await response.json();
|
|
350
|
-
if (!response.ok) throw new Error(`GitHub REST ${response.status}: ${JSON.stringify(body)}`);
|
|
351
|
-
return { body, headers: response.headers };
|
|
352
|
-
};
|
|
353
|
-
const graphqlRequest = async (query, variables = {}) => {
|
|
354
|
-
const response = await fetchImpl('https://api.github.com/graphql', {
|
|
355
|
-
method: 'POST',
|
|
356
|
-
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${config.token}` },
|
|
357
|
-
body: JSON.stringify({ query, variables }),
|
|
358
|
-
});
|
|
359
|
-
const body = await response.json();
|
|
360
|
-
if (!response.ok || body.errors) throw new Error(formatGraphqlError(body.errors || body));
|
|
361
|
-
return body.data;
|
|
362
|
-
};
|
|
363
|
-
|
|
364
|
-
const issues = await listIssuesForSync({
|
|
365
|
-
repoOwner: config.repoOwner,
|
|
366
|
-
repoName: config.repoName,
|
|
367
|
-
githubRequest,
|
|
368
|
-
eventPath: config.eventPath,
|
|
369
|
-
now: new Date(),
|
|
370
|
-
closedLookbackDays: config.closedLookbackDays,
|
|
371
|
-
});
|
|
372
|
-
const context = await getProjectContext({
|
|
373
|
-
owner: config.owner,
|
|
374
|
-
projectNumber: config.projectNumber,
|
|
375
|
-
repoFullName: config.repoFullName,
|
|
376
|
-
graphqlRequest,
|
|
377
|
-
});
|
|
378
|
-
await syncIssues({ issues, context, graphqlRequest, logger, now: new Date() });
|
|
379
|
-
}
|