@elench/testkit 0.1.149 → 0.1.151
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/README.md +29 -9
- package/lib/cli/assistant/view-model.mjs +1 -1
- package/lib/cli/components/blocks/run-tree.mjs +1 -1
- package/lib/cli/renderers/run/events.mjs +4 -3
- package/lib/cli/renderers/run/failure.mjs +2 -2
- package/lib/cli/renderers/run/inline-detail.mjs +2 -2
- package/lib/cli/renderers/run/interactive.mjs +2 -2
- package/lib/cli/renderers/run/text-reporter.mjs +9 -9
- package/lib/cli/state/run/model.mjs +7 -7
- package/lib/cli/state/run/state.mjs +3 -3
- package/lib/cli/terminal/colors.mjs +1 -1
- package/lib/config/database-materialization.mjs +25 -0
- package/lib/config/database.mjs +30 -0
- package/lib/config/index.mjs +47 -1
- package/lib/config/runtime.mjs +130 -0
- package/lib/config-api/index.d.ts +28 -0
- package/lib/config-api/index.mjs +6 -0
- package/lib/database/cleanup.mjs +76 -1
- package/lib/database/constants.mjs +3 -0
- package/lib/database/index.mjs +6 -0
- package/lib/database/local-postgres.mjs +123 -4
- package/lib/database/naming.mjs +7 -0
- package/lib/database/resource-postgres.mjs +13 -0
- package/lib/database/state-files.mjs +17 -0
- package/lib/docker-compat/matrix.mjs +5 -3
- package/lib/kiln/client.mjs +8 -0
- package/lib/local/kiln-driver.mjs +96 -68
- package/lib/ownership/docker.mjs +67 -1
- package/lib/regressions/github-transport.mjs +178 -4
- package/lib/regressions/github.mjs +52 -16
- package/lib/regressions/index.d.ts +58 -29
- package/lib/regressions/index.mjs +171 -58
- package/lib/regressions/workflow.mjs +266 -0
- package/lib/results/artifacts.mjs +8 -7
- package/lib/runner/formatting.mjs +17 -16
- package/lib/runner/orchestrator.mjs +6 -5
- package/lib/runner/planning.mjs +40 -0
- package/lib/runner/regressions.mjs +183 -33
- package/lib/runner/reporting.mjs +1 -1
- package/lib/runner/run-finalization.mjs +34 -4
- package/lib/runner/runtime-manager.mjs +91 -10
- package/lib/runner/scheduler/index.mjs +30 -1
- package/lib/runtime/index.d.ts +5 -5
- package/lib/runtime-src/k6/http.js +11 -11
- package/node_modules/@elench/next-analysis/package.json +1 -1
- package/node_modules/@elench/testkit-bridge/package.json +2 -2
- package/node_modules/@elench/testkit-protocol/package.json +1 -1
- package/node_modules/@elench/ts-analysis/package.json +1 -1
- package/node_modules/es-toolkit/CHANGELOG.md +801 -0
- package/node_modules/es-toolkit/src/compat/_internal/Equals.d.ts +1 -0
- package/node_modules/es-toolkit/src/compat/_internal/IsWritable.d.ts +3 -0
- package/node_modules/es-toolkit/src/compat/_internal/MutableList.d.ts +4 -0
- package/node_modules/es-toolkit/src/compat/_internal/RejectReadonly.d.ts +4 -0
- package/node_modules/esprima/ChangeLog +235 -0
- package/package.json +6 -5
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.d.ts +0 -188
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.d.ts.map +0 -1
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.js +0 -293
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.js.map +0 -1
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/package.json +0 -25
|
@@ -112,12 +112,16 @@ export async function kilnLocalDown(context, name, options = {}) {
|
|
|
112
112
|
const manifest = readLocalEnvironmentManifest(context.productDir, name);
|
|
113
113
|
if (!manifest) return null;
|
|
114
114
|
if (!manifest.kiln?.vm?.name) {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
115
|
+
if (options.destroyState) {
|
|
116
|
+
removeKilnEnvironmentState(context.productDir, name);
|
|
117
|
+
} else {
|
|
118
|
+
writeKilnManifest(context.productDir, name, {
|
|
119
|
+
...manifest,
|
|
120
|
+
status: "stopped",
|
|
121
|
+
stoppedAt: new Date().toISOString(),
|
|
122
|
+
services: [],
|
|
123
|
+
});
|
|
124
|
+
}
|
|
121
125
|
return manifest;
|
|
122
126
|
}
|
|
123
127
|
const ssh = await sshFromManifest(manifest);
|
|
@@ -132,14 +136,17 @@ export async function kilnLocalDown(context, name, options = {}) {
|
|
|
132
136
|
if (result.exitCode !== 0) {
|
|
133
137
|
throw new Error(`remote testkit local down failed\n${result.stdout}${result.stderr}`);
|
|
134
138
|
}
|
|
135
|
-
writeKilnManifest(context.productDir, name, {
|
|
136
|
-
...manifest,
|
|
137
|
-
status: "stopped",
|
|
138
|
-
stoppedAt: new Date().toISOString(),
|
|
139
|
-
services: [],
|
|
140
|
-
});
|
|
141
139
|
if (options.destroyState) {
|
|
142
|
-
await deleteManifestResources(manifest);
|
|
140
|
+
await deleteManifestResources(manifest, { includeEnvironmentVM: true });
|
|
141
|
+
await cleanupKilnOrphans(manifest);
|
|
142
|
+
removeKilnEnvironmentState(context.productDir, name);
|
|
143
|
+
} else {
|
|
144
|
+
writeKilnManifest(context.productDir, name, {
|
|
145
|
+
...manifest,
|
|
146
|
+
status: "stopped",
|
|
147
|
+
stoppedAt: new Date().toISOString(),
|
|
148
|
+
services: [],
|
|
149
|
+
});
|
|
143
150
|
}
|
|
144
151
|
return manifest;
|
|
145
152
|
} finally {
|
|
@@ -250,16 +257,16 @@ async function attachExistingVMs(client, network, vmRefs) {
|
|
|
250
257
|
}
|
|
251
258
|
|
|
252
259
|
async function ensureResources(client, environment, network) {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
260
|
+
const configured = flattenEnvironmentResources(environment.resources || {});
|
|
261
|
+
const manifest = {};
|
|
262
|
+
const connections = {};
|
|
263
|
+
for (const [name, resource] of Object.entries(configured)) {
|
|
264
|
+
if (resource.kind === "postgres") {
|
|
265
|
+
const appliance = await client.ensureAppliance(buildPostgresApplianceRequest(name, resource, environment, network));
|
|
266
|
+
manifest[name] = pickAppliance(appliance);
|
|
267
|
+
connections[name] = appliance.connection || {};
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
263
270
|
if (resource.kind === "server") {
|
|
264
271
|
const appliance = await client.ensureAppliance({
|
|
265
272
|
name: resource.vm?.name || `${environment.name}-${name}`,
|
|
@@ -279,7 +286,7 @@ async function ensureResources(client, environment, network) {
|
|
|
279
286
|
connections[name] = appliance.connection || {};
|
|
280
287
|
}
|
|
281
288
|
}
|
|
282
|
-
|
|
289
|
+
return { manifest, connections };
|
|
283
290
|
}
|
|
284
291
|
|
|
285
292
|
export function flattenEnvironmentResources(resources = {}) {
|
|
@@ -290,68 +297,89 @@ export function flattenEnvironmentResources(resources = {}) {
|
|
|
290
297
|
}
|
|
291
298
|
|
|
292
299
|
export function buildPostgresApplianceRequest(name, resource, environment, network) {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
300
|
+
const extensions = normalizePostgresExtensions(resource.extensions);
|
|
301
|
+
const image = resolvePostgresResourceImage(resource, name);
|
|
302
|
+
return {
|
|
303
|
+
name: resource.vm?.name || `${environment.name}-${name}`,
|
|
304
|
+
kind: "postgres",
|
|
305
|
+
network_id: resource.vm?.networkId || network?.id || "",
|
|
306
|
+
profile: resource.vm?.profile || "ubuntu-docker",
|
|
307
|
+
disk_size_mb: parseDiskMB(resource.vm?.disk || resource.vm?.diskSize || resource.vm?.diskSizeMB || resource.disk || "24G"),
|
|
308
|
+
memory_mb: Number(resource.vm?.memoryMB || resource.memoryMB || 1536),
|
|
309
|
+
vcpus: Number(resource.vm?.vcpus || resource.vcpus || 1),
|
|
310
|
+
autostart: Boolean(resource.vm?.autostart || resource.autostart),
|
|
311
|
+
postgres: {
|
|
312
|
+
version: resource.version || "16",
|
|
313
|
+
...(image ? { image } : {}),
|
|
314
|
+
database: resource.database || name,
|
|
315
|
+
user: resource.user || "app",
|
|
316
|
+
...(resource.password ? { password: resource.password } : {}),
|
|
317
|
+
...(resource.port ? { port: Number(resource.port) } : {}),
|
|
318
|
+
...(resource.maxConnections ? { max_connections: Number(resource.maxConnections) } : {}),
|
|
319
|
+
},
|
|
320
|
+
metadata: {
|
|
321
|
+
"testkit.environment": environment.name,
|
|
322
|
+
"testkit.resource": name,
|
|
323
|
+
...(extensions.length > 0 ? { "testkit.postgres.extensions": extensions.join(",") } : {}),
|
|
324
|
+
},
|
|
325
|
+
};
|
|
318
326
|
}
|
|
319
327
|
|
|
320
328
|
export function resolvePostgresResourceImage(resource, resourceName = "postgres") {
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
329
|
+
if (resource.image) return resource.image;
|
|
330
|
+
const extensions = normalizePostgresExtensions(resource.extensions);
|
|
331
|
+
if (extensions.length === 0) return null;
|
|
332
|
+
const unsupported = extensions.filter((extension) => extension !== "vector" && extension !== "pgvector");
|
|
333
|
+
if (unsupported.length > 0) {
|
|
334
|
+
throw new Error(
|
|
335
|
+
`Postgres resource "${resourceName}" requests unsupported extensions: ${unsupported.join(", ")}. ` +
|
|
336
|
+
`Set resource.postgres({ image }) to provide a custom Postgres image.`
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
return `pgvector/pgvector:pg${postgresMajorVersion(resource.version || "16")}-trixie`;
|
|
332
340
|
}
|
|
333
341
|
|
|
334
342
|
function normalizePostgresExtensions(extensions) {
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
343
|
+
if (extensions == null) return [];
|
|
344
|
+
if (!Array.isArray(extensions)) throw new Error("resource.postgres({ extensions }) must be an array");
|
|
345
|
+
return [...new Set(extensions.map((extension) => String(extension).trim().toLowerCase()).filter(Boolean))].sort();
|
|
338
346
|
}
|
|
339
347
|
|
|
340
348
|
function postgresMajorVersion(version) {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
349
|
+
const match = String(version || "16").match(/^\d+/);
|
|
350
|
+
if (!match) throw new Error(`Invalid Postgres version for resource image selection: ${version}`);
|
|
351
|
+
return match[0];
|
|
344
352
|
}
|
|
345
353
|
|
|
346
|
-
async function deleteManifestResources(manifest) {
|
|
354
|
+
async function deleteManifestResources(manifest, options = {}) {
|
|
347
355
|
const resources = manifest.resources || {};
|
|
348
|
-
if (Object.keys(resources).length === 0) return;
|
|
349
356
|
const client = new KilnClient(manifest.kiln?.api || {});
|
|
350
357
|
for (const resource of Object.values(resources)) {
|
|
351
358
|
if (resource?.name) {
|
|
352
359
|
await client.deleteAppliance(resource.name).catch(() => {});
|
|
353
360
|
}
|
|
354
361
|
}
|
|
362
|
+
if (options.includeEnvironmentVM && manifest.kiln?.vm?.name) {
|
|
363
|
+
await client.deleteVM(manifest.kiln.vm.name).catch(() => {});
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function cleanupKilnOrphans(manifest) {
|
|
368
|
+
const client = new KilnClient(manifest.kiln?.api || {});
|
|
369
|
+
await client.cleanup({ dry_run: false });
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function removeKilnEnvironmentState(productDir, name) {
|
|
373
|
+
const environmentDir = getEnvironmentDir(productDir, name);
|
|
374
|
+
fs.rmSync(environmentDir, { recursive: true, force: true });
|
|
375
|
+
const parent = path.dirname(environmentDir);
|
|
376
|
+
try {
|
|
377
|
+
if (fs.existsSync(parent) && fs.readdirSync(parent).length === 0) {
|
|
378
|
+
fs.rmdirSync(parent);
|
|
379
|
+
}
|
|
380
|
+
} catch {
|
|
381
|
+
// Best-effort pruning only.
|
|
382
|
+
}
|
|
355
383
|
}
|
|
356
384
|
|
|
357
385
|
async function ensureVM(client, vmConfig, networkId) {
|
package/lib/ownership/docker.mjs
CHANGED
|
@@ -57,6 +57,23 @@ export async function listManagedDockerContainers() {
|
|
|
57
57
|
]);
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
export async function inspectDockerVolume(volumeRef) {
|
|
61
|
+
try {
|
|
62
|
+
const { stdout } = await execa("docker", ["volume", "inspect", volumeRef]);
|
|
63
|
+
const parsed = JSON.parse(stdout);
|
|
64
|
+
return parsed[0] || null;
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function listManagedDockerVolumes() {
|
|
71
|
+
return listDockerVolumes([
|
|
72
|
+
"--filter",
|
|
73
|
+
`label=${TESTKIT_MANAGED_LABEL}=true`,
|
|
74
|
+
]);
|
|
75
|
+
}
|
|
76
|
+
|
|
60
77
|
export async function listLegacyTestkitPostgresContainers() {
|
|
61
78
|
const containers = await listDockerContainers([]);
|
|
62
79
|
return containers.filter((container) => {
|
|
@@ -67,7 +84,16 @@ export async function listLegacyTestkitPostgresContainers() {
|
|
|
67
84
|
|
|
68
85
|
export async function removeDockerContainer(containerRef) {
|
|
69
86
|
try {
|
|
70
|
-
await execa("docker", ["rm", "-f", containerRef]);
|
|
87
|
+
await execa("docker", ["rm", "-f", "-v", containerRef]);
|
|
88
|
+
return true;
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function removeDockerVolume(volumeRef) {
|
|
95
|
+
try {
|
|
96
|
+
await execa("docker", ["volume", "rm", "-f", volumeRef]);
|
|
71
97
|
return true;
|
|
72
98
|
} catch {
|
|
73
99
|
return false;
|
|
@@ -88,6 +114,10 @@ export function dockerContainerSummary(container) {
|
|
|
88
114
|
return `${container.name} (${status})`;
|
|
89
115
|
}
|
|
90
116
|
|
|
117
|
+
export function dockerVolumeSummary(volume) {
|
|
118
|
+
return `${volume.name}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
91
121
|
async function listDockerContainers(filters) {
|
|
92
122
|
let stdout = "";
|
|
93
123
|
try {
|
|
@@ -114,6 +144,31 @@ async function listDockerContainers(filters) {
|
|
|
114
144
|
return containers.map(normalizeInspectContainer).filter(Boolean);
|
|
115
145
|
}
|
|
116
146
|
|
|
147
|
+
async function listDockerVolumes(filters) {
|
|
148
|
+
let stdout = "";
|
|
149
|
+
try {
|
|
150
|
+
const result = await execa("docker", [
|
|
151
|
+
"volume",
|
|
152
|
+
"ls",
|
|
153
|
+
"-q",
|
|
154
|
+
...filters,
|
|
155
|
+
]);
|
|
156
|
+
stdout = result.stdout;
|
|
157
|
+
} catch {
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const names = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
162
|
+
if (names.length === 0) return [];
|
|
163
|
+
|
|
164
|
+
const volumes = [];
|
|
165
|
+
for (const name of names) {
|
|
166
|
+
const inspect = await inspectDockerVolume(name);
|
|
167
|
+
if (inspect) volumes.push(inspect);
|
|
168
|
+
}
|
|
169
|
+
return volumes.map(normalizeInspectVolume).filter(Boolean);
|
|
170
|
+
}
|
|
171
|
+
|
|
117
172
|
function normalizeInspectContainer(inspect) {
|
|
118
173
|
if (!inspect?.Id) return null;
|
|
119
174
|
const name = String(inspect.Name || "").replace(/^\//, "");
|
|
@@ -130,6 +185,17 @@ function normalizeInspectContainer(inspect) {
|
|
|
130
185
|
};
|
|
131
186
|
}
|
|
132
187
|
|
|
188
|
+
function normalizeInspectVolume(inspect) {
|
|
189
|
+
if (!inspect?.Name) return null;
|
|
190
|
+
return {
|
|
191
|
+
name: inspect.Name,
|
|
192
|
+
driver: inspect.Driver || "",
|
|
193
|
+
mountpoint: inspect.Mountpoint || "",
|
|
194
|
+
labels: inspect.Labels || {},
|
|
195
|
+
createdAt: inspect.CreatedAt || null,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
133
199
|
function canonicalizeProductDir(productDir) {
|
|
134
200
|
const resolved = path.resolve(productDir || process.cwd());
|
|
135
201
|
try {
|
|
@@ -19,13 +19,26 @@ export function parseGitHubRepoSlug(remoteUrl) {
|
|
|
19
19
|
return null;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
export async function createDefaultGitHubIssueTransport(env = process.env) {
|
|
22
|
+
export async function createDefaultGitHubIssueTransport(env = process.env, options = {}) {
|
|
23
23
|
const token = env.GH_TOKEN || env.GITHUB_TOKEN || null;
|
|
24
|
+
const apiBaseUrl = normalizeOptionalString(options.apiBaseUrl || env.TESTKIT_GITHUB_API_URL) || "https://api.github.com";
|
|
24
25
|
if (token) {
|
|
25
26
|
return {
|
|
26
27
|
type: "token",
|
|
27
28
|
async fetchRepoIssues(repo, numbers) {
|
|
28
|
-
return fetchRepoIssuesViaToken(repo, numbers, token);
|
|
29
|
+
return fetchRepoIssuesViaToken(repo, numbers, token, { apiBaseUrl });
|
|
30
|
+
},
|
|
31
|
+
async fetchOpenBugIssues(repo, options = {}) {
|
|
32
|
+
return fetchOpenBugIssuesViaRest(repo, token, { apiBaseUrl, ...options });
|
|
33
|
+
},
|
|
34
|
+
async createIssue(repo, input) {
|
|
35
|
+
return createIssueViaRest(repo, input, token, { apiBaseUrl });
|
|
36
|
+
},
|
|
37
|
+
async updateIssueState(repo, number, state) {
|
|
38
|
+
return updateIssueStateViaRest(repo, number, state, token, { apiBaseUrl });
|
|
39
|
+
},
|
|
40
|
+
async createIssueComment(repo, number, body) {
|
|
41
|
+
return createIssueCommentViaRest(repo, number, body, token, { apiBaseUrl });
|
|
29
42
|
},
|
|
30
43
|
};
|
|
31
44
|
}
|
|
@@ -41,6 +54,18 @@ export async function createDefaultGitHubIssueTransport(env = process.env) {
|
|
|
41
54
|
async fetchRepoIssues(repo, numbers) {
|
|
42
55
|
return fetchRepoIssuesViaGh(repo, numbers, env);
|
|
43
56
|
},
|
|
57
|
+
async fetchOpenBugIssues(repo, options = {}) {
|
|
58
|
+
return fetchOpenBugIssuesViaGh(repo, options, env);
|
|
59
|
+
},
|
|
60
|
+
async createIssue(repo, input) {
|
|
61
|
+
return createIssueViaGh(repo, input, env);
|
|
62
|
+
},
|
|
63
|
+
async updateIssueState(repo, number, state) {
|
|
64
|
+
return updateIssueStateViaGh(repo, number, state, env);
|
|
65
|
+
},
|
|
66
|
+
async createIssueComment(repo, number, body) {
|
|
67
|
+
return createIssueCommentViaGh(repo, number, body, env);
|
|
68
|
+
},
|
|
44
69
|
};
|
|
45
70
|
} catch {
|
|
46
71
|
return null;
|
|
@@ -65,9 +90,9 @@ export async function fetchIssuesByRepo(client, issueNumbersByRepo) {
|
|
|
65
90
|
return issuesByRepo;
|
|
66
91
|
}
|
|
67
92
|
|
|
68
|
-
async function fetchRepoIssuesViaToken(repo, numbers, token) {
|
|
93
|
+
async function fetchRepoIssuesViaToken(repo, numbers, token, { apiBaseUrl = "https://api.github.com" } = {}) {
|
|
69
94
|
const query = buildIssueQuery(repo, numbers);
|
|
70
|
-
const response = await fetch("
|
|
95
|
+
const response = await fetch(`${apiBaseUrl.replace(/\/+$/u, "")}/graphql`, {
|
|
71
96
|
method: "POST",
|
|
72
97
|
headers: {
|
|
73
98
|
"Content-Type": "application/json",
|
|
@@ -90,6 +115,61 @@ async function fetchRepoIssuesViaToken(repo, numbers, token) {
|
|
|
90
115
|
return normalizeGraphqlIssuesResponse(repo, numbers, payload?.data);
|
|
91
116
|
}
|
|
92
117
|
|
|
118
|
+
async function fetchOpenBugIssuesViaRest(repo, token, { apiBaseUrl = "https://api.github.com", labels = ["bug"] } = {}) {
|
|
119
|
+
const labelQuery = labels.length > 0 ? `&labels=${encodeURIComponent(labels.join(","))}` : "";
|
|
120
|
+
const response = await fetch(`${apiBaseUrl.replace(/\/+$/u, "")}/repos/${repo}/issues?state=open${labelQuery}`, {
|
|
121
|
+
headers: githubRestHeaders(token),
|
|
122
|
+
});
|
|
123
|
+
const payload = await response.json().catch(() => null);
|
|
124
|
+
if (!response.ok) {
|
|
125
|
+
throw new Error(`GitHub issue list failed with ${response.status}${payload?.message ? `: ${payload.message}` : ""}`);
|
|
126
|
+
}
|
|
127
|
+
return normalizeRestIssues(repo, Array.isArray(payload) ? payload : []);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function createIssueViaRest(repo, input, token, { apiBaseUrl = "https://api.github.com" } = {}) {
|
|
131
|
+
const response = await fetch(`${apiBaseUrl.replace(/\/+$/u, "")}/repos/${repo}/issues`, {
|
|
132
|
+
method: "POST",
|
|
133
|
+
headers: githubRestHeaders(token),
|
|
134
|
+
body: JSON.stringify({
|
|
135
|
+
title: input.title,
|
|
136
|
+
body: input.body,
|
|
137
|
+
labels: input.labels || [],
|
|
138
|
+
}),
|
|
139
|
+
});
|
|
140
|
+
const payload = await response.json().catch(() => null);
|
|
141
|
+
if (!response.ok) {
|
|
142
|
+
throw new Error(`GitHub issue create failed with ${response.status}${payload?.message ? `: ${payload.message}` : ""}`);
|
|
143
|
+
}
|
|
144
|
+
return normalizeRestIssue(repo, payload);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function updateIssueStateViaRest(repo, number, state, token, { apiBaseUrl = "https://api.github.com" } = {}) {
|
|
148
|
+
const response = await fetch(`${apiBaseUrl.replace(/\/+$/u, "")}/repos/${repo}/issues/${number}`, {
|
|
149
|
+
method: "PATCH",
|
|
150
|
+
headers: githubRestHeaders(token),
|
|
151
|
+
body: JSON.stringify({ state }),
|
|
152
|
+
});
|
|
153
|
+
const payload = await response.json().catch(() => null);
|
|
154
|
+
if (!response.ok) {
|
|
155
|
+
throw new Error(`GitHub issue update failed with ${response.status}${payload?.message ? `: ${payload.message}` : ""}`);
|
|
156
|
+
}
|
|
157
|
+
return normalizeRestIssue(repo, payload);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function createIssueCommentViaRest(repo, number, body, token, { apiBaseUrl = "https://api.github.com" } = {}) {
|
|
161
|
+
const response = await fetch(`${apiBaseUrl.replace(/\/+$/u, "")}/repos/${repo}/issues/${number}/comments`, {
|
|
162
|
+
method: "POST",
|
|
163
|
+
headers: githubRestHeaders(token),
|
|
164
|
+
body: JSON.stringify({ body }),
|
|
165
|
+
});
|
|
166
|
+
const payload = await response.json().catch(() => null);
|
|
167
|
+
if (!response.ok) {
|
|
168
|
+
throw new Error(`GitHub issue comment failed with ${response.status}${payload?.message ? `: ${payload.message}` : ""}`);
|
|
169
|
+
}
|
|
170
|
+
return payload;
|
|
171
|
+
}
|
|
172
|
+
|
|
93
173
|
async function fetchRepoIssuesViaGh(repo, numbers, env) {
|
|
94
174
|
const query = buildIssueQuery(repo, numbers);
|
|
95
175
|
const { stdout } = await execFileAsync("gh", ["api", "graphql", "-f", `query=${query}`], {
|
|
@@ -104,6 +184,65 @@ async function fetchRepoIssuesViaGh(repo, numbers, env) {
|
|
|
104
184
|
return normalizeGraphqlIssuesResponse(repo, numbers, payload?.data);
|
|
105
185
|
}
|
|
106
186
|
|
|
187
|
+
async function fetchOpenBugIssuesViaGh(repo, { labels = ["bug"] } = {}, env) {
|
|
188
|
+
const args = ["issue", "list", "--repo", repo, "--state", "open", "--json", "number,title,state,url,labels"];
|
|
189
|
+
for (const label of labels) args.push("--label", label);
|
|
190
|
+
const { stdout } = await execFileAsync("gh", args, {
|
|
191
|
+
encoding: "utf8",
|
|
192
|
+
env,
|
|
193
|
+
maxBuffer: 1024 * 1024,
|
|
194
|
+
});
|
|
195
|
+
return normalizeRestIssues(repo, JSON.parse(stdout || "[]"));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function createIssueViaGh(repo, input, env) {
|
|
199
|
+
const args = ["issue", "create", "--repo", repo, "--title", input.title, "--body", input.body || ""];
|
|
200
|
+
for (const label of input.labels || []) args.push("--label", label);
|
|
201
|
+
const { stdout } = await execFileAsync("gh", args, {
|
|
202
|
+
encoding: "utf8",
|
|
203
|
+
env,
|
|
204
|
+
maxBuffer: 1024 * 1024,
|
|
205
|
+
});
|
|
206
|
+
const url = stdout.trim();
|
|
207
|
+
const number = Number(url.match(/\/issues\/(\d+)$/u)?.[1]);
|
|
208
|
+
return {
|
|
209
|
+
repo,
|
|
210
|
+
number,
|
|
211
|
+
exists: Number.isInteger(number),
|
|
212
|
+
title: input.title,
|
|
213
|
+
state: "OPEN",
|
|
214
|
+
url,
|
|
215
|
+
labels: input.labels || [],
|
|
216
|
+
source: "github",
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function updateIssueStateViaGh(repo, number, state, env) {
|
|
221
|
+
await execFileAsync("gh", ["issue", state === "open" ? "reopen" : "close", String(number), "--repo", repo], {
|
|
222
|
+
encoding: "utf8",
|
|
223
|
+
env,
|
|
224
|
+
maxBuffer: 1024 * 1024,
|
|
225
|
+
});
|
|
226
|
+
return {
|
|
227
|
+
repo,
|
|
228
|
+
number,
|
|
229
|
+
exists: true,
|
|
230
|
+
title: null,
|
|
231
|
+
state: state.toUpperCase(),
|
|
232
|
+
url: `https://github.com/${repo}/issues/${number}`,
|
|
233
|
+
source: "github",
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function createIssueCommentViaGh(repo, number, body, env) {
|
|
238
|
+
await execFileAsync("gh", ["issue", "comment", String(number), "--repo", repo, "--body", body], {
|
|
239
|
+
encoding: "utf8",
|
|
240
|
+
env,
|
|
241
|
+
maxBuffer: 1024 * 1024,
|
|
242
|
+
});
|
|
243
|
+
return { ok: true };
|
|
244
|
+
}
|
|
245
|
+
|
|
107
246
|
function buildIssueQuery(repo, numbers) {
|
|
108
247
|
const [owner, name] = repo.split("/");
|
|
109
248
|
return `
|
|
@@ -159,6 +298,41 @@ function normalizeGraphqlIssuesResponse(repo, numbers, data) {
|
|
|
159
298
|
return map;
|
|
160
299
|
}
|
|
161
300
|
|
|
301
|
+
function normalizeRestIssues(repo, issues) {
|
|
302
|
+
const map = new Map();
|
|
303
|
+
for (const issue of issues) {
|
|
304
|
+
const normalized = normalizeRestIssue(repo, issue);
|
|
305
|
+
if (normalized?.number) map.set(normalized.number, normalized);
|
|
306
|
+
}
|
|
307
|
+
return map;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function normalizeRestIssue(repo, issue) {
|
|
311
|
+
if (!issue || typeof issue !== "object") return null;
|
|
312
|
+
return {
|
|
313
|
+
repo,
|
|
314
|
+
number: Number(issue.number),
|
|
315
|
+
exists: true,
|
|
316
|
+
title: normalizeOptionalString(issue.title),
|
|
317
|
+
state: normalizeOptionalString(issue.state)?.toUpperCase() || null,
|
|
318
|
+
url: normalizeOptionalString(issue.html_url || issue.url),
|
|
319
|
+
labels: Array.isArray(issue.labels)
|
|
320
|
+
? issue.labels.map((label) => typeof label === "string" ? label : label?.name).filter(Boolean)
|
|
321
|
+
: [],
|
|
322
|
+
checkedAt: null,
|
|
323
|
+
source: "github",
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function githubRestHeaders(token) {
|
|
328
|
+
return {
|
|
329
|
+
"Content-Type": "application/json",
|
|
330
|
+
Accept: "application/vnd.github+json",
|
|
331
|
+
Authorization: `Bearer ${token}`,
|
|
332
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
162
336
|
function normalizeOptionalString(value) {
|
|
163
337
|
if (typeof value !== "string") return null;
|
|
164
338
|
const normalized = value.trim();
|