@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.
Files changed (60) hide show
  1. package/README.md +29 -9
  2. package/lib/cli/assistant/view-model.mjs +1 -1
  3. package/lib/cli/components/blocks/run-tree.mjs +1 -1
  4. package/lib/cli/renderers/run/events.mjs +4 -3
  5. package/lib/cli/renderers/run/failure.mjs +2 -2
  6. package/lib/cli/renderers/run/inline-detail.mjs +2 -2
  7. package/lib/cli/renderers/run/interactive.mjs +2 -2
  8. package/lib/cli/renderers/run/text-reporter.mjs +9 -9
  9. package/lib/cli/state/run/model.mjs +7 -7
  10. package/lib/cli/state/run/state.mjs +3 -3
  11. package/lib/cli/terminal/colors.mjs +1 -1
  12. package/lib/config/database-materialization.mjs +25 -0
  13. package/lib/config/database.mjs +30 -0
  14. package/lib/config/index.mjs +47 -1
  15. package/lib/config/runtime.mjs +130 -0
  16. package/lib/config-api/index.d.ts +28 -0
  17. package/lib/config-api/index.mjs +6 -0
  18. package/lib/database/cleanup.mjs +76 -1
  19. package/lib/database/constants.mjs +3 -0
  20. package/lib/database/index.mjs +6 -0
  21. package/lib/database/local-postgres.mjs +123 -4
  22. package/lib/database/naming.mjs +7 -0
  23. package/lib/database/resource-postgres.mjs +13 -0
  24. package/lib/database/state-files.mjs +17 -0
  25. package/lib/docker-compat/matrix.mjs +5 -3
  26. package/lib/kiln/client.mjs +8 -0
  27. package/lib/local/kiln-driver.mjs +96 -68
  28. package/lib/ownership/docker.mjs +67 -1
  29. package/lib/regressions/github-transport.mjs +178 -4
  30. package/lib/regressions/github.mjs +52 -16
  31. package/lib/regressions/index.d.ts +58 -29
  32. package/lib/regressions/index.mjs +171 -58
  33. package/lib/regressions/workflow.mjs +266 -0
  34. package/lib/results/artifacts.mjs +8 -7
  35. package/lib/runner/formatting.mjs +17 -16
  36. package/lib/runner/orchestrator.mjs +6 -5
  37. package/lib/runner/planning.mjs +40 -0
  38. package/lib/runner/regressions.mjs +183 -33
  39. package/lib/runner/reporting.mjs +1 -1
  40. package/lib/runner/run-finalization.mjs +34 -4
  41. package/lib/runner/runtime-manager.mjs +91 -10
  42. package/lib/runner/scheduler/index.mjs +30 -1
  43. package/lib/runtime/index.d.ts +5 -5
  44. package/lib/runtime-src/k6/http.js +11 -11
  45. package/node_modules/@elench/next-analysis/package.json +1 -1
  46. package/node_modules/@elench/testkit-bridge/package.json +2 -2
  47. package/node_modules/@elench/testkit-protocol/package.json +1 -1
  48. package/node_modules/@elench/ts-analysis/package.json +1 -1
  49. package/node_modules/es-toolkit/CHANGELOG.md +801 -0
  50. package/node_modules/es-toolkit/src/compat/_internal/Equals.d.ts +1 -0
  51. package/node_modules/es-toolkit/src/compat/_internal/IsWritable.d.ts +3 -0
  52. package/node_modules/es-toolkit/src/compat/_internal/MutableList.d.ts +4 -0
  53. package/node_modules/es-toolkit/src/compat/_internal/RejectReadonly.d.ts +4 -0
  54. package/node_modules/esprima/ChangeLog +235 -0
  55. package/package.json +6 -5
  56. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.d.ts +0 -188
  57. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.d.ts.map +0 -1
  58. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.js +0 -293
  59. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.js.map +0 -1
  60. 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
- writeKilnManifest(context.productDir, name, {
116
- ...manifest,
117
- status: "stopped",
118
- stoppedAt: new Date().toISOString(),
119
- services: [],
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
- const configured = flattenEnvironmentResources(environment.resources || {});
254
- const manifest = {};
255
- const connections = {};
256
- for (const [name, resource] of Object.entries(configured)) {
257
- if (resource.kind === "postgres") {
258
- const appliance = await client.ensureAppliance(buildPostgresApplianceRequest(name, resource, environment, network));
259
- manifest[name] = pickAppliance(appliance);
260
- connections[name] = appliance.connection || {};
261
- continue;
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
- return { manifest, connections };
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
- const extensions = normalizePostgresExtensions(resource.extensions);
294
- const image = resolvePostgresResourceImage(resource, name);
295
- return {
296
- name: resource.vm?.name || `${environment.name}-${name}`,
297
- kind: "postgres",
298
- network_id: resource.vm?.networkId || network?.id || "",
299
- profile: resource.vm?.profile || "ubuntu-docker",
300
- disk_size_mb: parseDiskMB(resource.vm?.disk || resource.vm?.diskSize || resource.vm?.diskSizeMB || resource.disk || "24G"),
301
- memory_mb: Number(resource.vm?.memoryMB || resource.memoryMB || 1536),
302
- vcpus: Number(resource.vm?.vcpus || resource.vcpus || 1),
303
- autostart: Boolean(resource.vm?.autostart || resource.autostart),
304
- postgres: {
305
- version: resource.version || "16",
306
- ...(image ? { image } : {}),
307
- database: resource.database || name,
308
- user: resource.user || "app",
309
- ...(resource.password ? { password: resource.password } : {}),
310
- ...(resource.port ? { port: Number(resource.port) } : {}),
311
- },
312
- metadata: {
313
- "testkit.environment": environment.name,
314
- "testkit.resource": name,
315
- ...(extensions.length > 0 ? { "testkit.postgres.extensions": extensions.join(",") } : {}),
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
- if (resource.image) return resource.image;
322
- const extensions = normalizePostgresExtensions(resource.extensions);
323
- if (extensions.length === 0) return null;
324
- const unsupported = extensions.filter((extension) => extension !== "vector" && extension !== "pgvector");
325
- if (unsupported.length > 0) {
326
- throw new Error(
327
- `Postgres resource "${resourceName}" requests unsupported extensions: ${unsupported.join(", ")}. ` +
328
- `Set resource.postgres({ image }) to provide a custom Postgres image.`
329
- );
330
- }
331
- return `pgvector/pgvector:pg${postgresMajorVersion(resource.version || "16")}-trixie`;
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
- if (extensions == null) return [];
336
- if (!Array.isArray(extensions)) throw new Error("resource.postgres({ extensions }) must be an array");
337
- return [...new Set(extensions.map((extension) => String(extension).trim().toLowerCase()).filter(Boolean))].sort();
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
- const match = String(version || "16").match(/^\d+/);
342
- if (!match) throw new Error(`Invalid Postgres version for resource image selection: ${version}`);
343
- return match[0];
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) {
@@ -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("https://api.github.com/graphql", {
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();