igel-qe-core 1.0.18 → 1.0.20
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/dist/cli/index.js +180 -71
- package/package.json +1 -1
- package/src/cli/index.ts +204 -80
- package/src/cli/workflow_cli.py +35 -5
package/dist/cli/index.js
CHANGED
|
@@ -220,34 +220,63 @@ function listFrameworkContextFiles(workspaceRoot) {
|
|
|
220
220
|
}
|
|
221
221
|
return Array.from(new Set(found)).sort();
|
|
222
222
|
}
|
|
223
|
-
function
|
|
224
|
-
//
|
|
225
|
-
const
|
|
223
|
+
function detectCopilotVariant() {
|
|
224
|
+
// Try `gh copilot --version` first (modern, official gh extension)
|
|
225
|
+
const ghCheck = spawnSync("gh", ["copilot", "--version"], {
|
|
226
226
|
encoding: "utf-8",
|
|
227
227
|
shell: process.platform === "win32",
|
|
228
|
-
timeout:
|
|
228
|
+
timeout: 8_000,
|
|
229
|
+
});
|
|
230
|
+
if (ghCheck.status === 0 && !ghCheck.error)
|
|
231
|
+
return "gh";
|
|
232
|
+
// Fallback: legacy standalone `copilot` binary
|
|
233
|
+
const legacyCheck = spawnSync("copilot", ["--version"], {
|
|
234
|
+
encoding: "utf-8",
|
|
235
|
+
shell: process.platform === "win32",
|
|
236
|
+
timeout: 8_000,
|
|
229
237
|
});
|
|
230
|
-
|
|
238
|
+
if (legacyCheck.status === 0 && !legacyCheck.error)
|
|
239
|
+
return "legacy";
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
function isCopilotCliAvailable() {
|
|
243
|
+
return detectCopilotVariant() !== null;
|
|
231
244
|
}
|
|
232
245
|
function isCopilotAuthenticated() {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
246
|
+
const variant = detectCopilotVariant();
|
|
247
|
+
if (!variant)
|
|
248
|
+
return false;
|
|
249
|
+
if (variant === "gh") {
|
|
250
|
+
// `gh auth status` exits 0 when authenticated
|
|
251
|
+
const probe = spawnSync("gh", ["auth", "status"], {
|
|
252
|
+
encoding: "utf-8",
|
|
253
|
+
shell: process.platform === "win32",
|
|
254
|
+
timeout: 10_000,
|
|
255
|
+
});
|
|
256
|
+
return probe.status === 0 && !probe.error;
|
|
257
|
+
}
|
|
258
|
+
// Legacy variant — try `copilot auth status`
|
|
236
259
|
const probe = spawnSync("copilot", ["auth", "status"], {
|
|
237
260
|
encoding: "utf-8",
|
|
238
261
|
shell: process.platform === "win32",
|
|
239
|
-
timeout: 10_000,
|
|
262
|
+
timeout: 10_000,
|
|
240
263
|
});
|
|
241
264
|
return probe.status === 0 && !probe.error;
|
|
242
265
|
}
|
|
243
266
|
function ensureCopilotLogin(workspaceRoot) {
|
|
244
|
-
|
|
267
|
+
const variant = detectCopilotVariant();
|
|
268
|
+
if (!variant)
|
|
245
269
|
return false;
|
|
246
270
|
if (isCopilotAuthenticated())
|
|
247
271
|
return true;
|
|
248
272
|
console.log(chalk.yellow(" ⚠ GitHub Copilot CLI is not authenticated."));
|
|
249
273
|
console.log(chalk.yellow(" → Please complete Copilot login to continue context enrichment."));
|
|
250
|
-
const
|
|
274
|
+
const loginCmd = variant === "gh" ? ["gh", "auth", "login"] : ["copilot", "login"];
|
|
275
|
+
const login = spawnSync(loginCmd[0], loginCmd.slice(1), {
|
|
276
|
+
stdio: "inherit",
|
|
277
|
+
encoding: "utf-8",
|
|
278
|
+
shell: process.platform === "win32",
|
|
279
|
+
});
|
|
251
280
|
if (login.status !== 0)
|
|
252
281
|
return false;
|
|
253
282
|
return isCopilotAuthenticated();
|
|
@@ -255,6 +284,59 @@ function ensureCopilotLogin(workspaceRoot) {
|
|
|
255
284
|
// Maximum number of context.md files to enrich per init run.
|
|
256
285
|
// Prevents extremely long waits on repos with many folders.
|
|
257
286
|
const MAX_ENRICHMENT_FILES = 15;
|
|
287
|
+
/**
|
|
288
|
+
* Attempt to enrich a single context.md file using `gh copilot suggest`.
|
|
289
|
+
* Returns the enriched text, or null on failure/empty response.
|
|
290
|
+
*/
|
|
291
|
+
function tryGhCopilotSuggest(workspaceRoot, relPath, currentContent) {
|
|
292
|
+
const prompt = [
|
|
293
|
+
`You are enriching a context.md file for an automation test framework.`,
|
|
294
|
+
`The file is located at: ${relPath} (relative to the workspace root).`,
|
|
295
|
+
``,
|
|
296
|
+
`Current content:`,
|
|
297
|
+
`\`\`\`markdown`,
|
|
298
|
+
currentContent,
|
|
299
|
+
`\`\`\``,
|
|
300
|
+
``,
|
|
301
|
+
`Rewrite the content above to be concrete, human-understandable, and LLM-ready.`,
|
|
302
|
+
`Requirements:`,
|
|
303
|
+
`- Keep the same heading structure (# Context, ## Purpose, ## Key Assets, ## Conventions, ## Risks and Dependencies).`,
|
|
304
|
+
`- Replace placeholder text with specific details inferred from the folder path and framework context.`,
|
|
305
|
+
`- Be concise but informative. Each section: 2–5 bullet points or short sentences.`,
|
|
306
|
+
`- Output ONLY the improved markdown. Do NOT add commentary, code fences, or extra text around it.`,
|
|
307
|
+
].join("\n");
|
|
308
|
+
const variant = detectCopilotVariant();
|
|
309
|
+
let result;
|
|
310
|
+
if (variant === "gh") {
|
|
311
|
+
// Modern: `gh copilot suggest -t shell "<prompt>"`
|
|
312
|
+
// We use --no-color so output can be parsed cleanly.
|
|
313
|
+
result = spawnSync("gh", ["copilot", "suggest", "-t", "shell", "--", prompt], {
|
|
314
|
+
cwd: workspaceRoot,
|
|
315
|
+
encoding: "utf-8",
|
|
316
|
+
shell: process.platform === "win32",
|
|
317
|
+
timeout: 120_000,
|
|
318
|
+
input: "\n", // auto-accept any interactive prompt
|
|
319
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
else if (variant === "legacy") {
|
|
323
|
+
// Legacy @githubnext/github-copilot-cli binary
|
|
324
|
+
result = spawnSync("copilot", ["-C", workspaceRoot, "-p", prompt, "--no-color", "-s"], {
|
|
325
|
+
encoding: "utf-8",
|
|
326
|
+
shell: process.platform === "win32",
|
|
327
|
+
timeout: 120_000,
|
|
328
|
+
input: "",
|
|
329
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
if (!result || result.status !== 0 || result.error)
|
|
336
|
+
return null;
|
|
337
|
+
const text = (result.stdout || "").trim();
|
|
338
|
+
return text.length > 50 ? text : null;
|
|
339
|
+
}
|
|
258
340
|
function enrichWithCopilotCli(workspaceRoot, contextFiles) {
|
|
259
341
|
let updated = 0;
|
|
260
342
|
let failed = 0;
|
|
@@ -268,71 +350,32 @@ function enrichWithCopilotCli(workspaceRoot, contextFiles) {
|
|
|
268
350
|
.replace(`${workspaceRoot}\\`, "")
|
|
269
351
|
.replace(`${workspaceRoot}/`, "")
|
|
270
352
|
.replace(/\\/g, "/");
|
|
271
|
-
// Show per-file progress so the user knows work is happening.
|
|
272
353
|
process.stdout.write(` [${i + 1}/${total}] Enriching ${relPath}…`);
|
|
273
|
-
// Read the current (template) content so Copilot can see the structure.
|
|
274
354
|
let currentContent = "";
|
|
275
355
|
try {
|
|
276
356
|
currentContent = readFileSync(absPath, "utf-8");
|
|
277
357
|
}
|
|
278
358
|
catch {
|
|
279
|
-
// If we can't read, skip this file.
|
|
280
359
|
process.stdout.write(" ✗ (unreadable)\n");
|
|
281
360
|
failed += 1;
|
|
282
361
|
continue;
|
|
283
362
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
`Current content:`,
|
|
291
|
-
`\`\`\`markdown`,
|
|
292
|
-
currentContent,
|
|
293
|
-
`\`\`\``,
|
|
294
|
-
``,
|
|
295
|
-
`Rewrite the content above to be concrete, human-understandable, and LLM-ready.`,
|
|
296
|
-
`Requirements:`,
|
|
297
|
-
`- Keep the same heading structure (# Context, ## Purpose, ## Key Assets, ## Conventions, ## Risks and Dependencies).`,
|
|
298
|
-
`- Replace placeholder text with specific details inferred from the folder path and framework context.`,
|
|
299
|
-
`- Be concise but informative. Each section: 2–5 bullet points or short sentences.`,
|
|
300
|
-
`- Output ONLY the improved markdown. Do NOT add commentary, code fences, or extra text around it.`,
|
|
301
|
-
].join("\n");
|
|
302
|
-
const result = spawnSync("copilot", [
|
|
303
|
-
"-C", workspaceRoot,
|
|
304
|
-
"-p", prompt,
|
|
305
|
-
"--no-color",
|
|
306
|
-
"-s",
|
|
307
|
-
], {
|
|
308
|
-
encoding: "utf-8",
|
|
309
|
-
shell: process.platform === "win32",
|
|
310
|
-
timeout: 120_000, // 2 min per file — prevents indefinite hangs
|
|
311
|
-
input: "", // close stdin immediately
|
|
312
|
-
maxBuffer: 4 * 1024 * 1024, // 4 MB stdout buffer
|
|
313
|
-
});
|
|
314
|
-
if (result.status === 0 && !result.error) {
|
|
315
|
-
const enriched = (result.stdout || "").trim();
|
|
316
|
-
if (enriched.length > 50) {
|
|
317
|
-
// Only write back if Copilot returned meaningful content (>50 chars).
|
|
318
|
-
try {
|
|
319
|
-
writeFileSync(absPath, enriched, "utf-8");
|
|
320
|
-
process.stdout.write(" ✓\n");
|
|
321
|
-
updated += 1;
|
|
322
|
-
}
|
|
323
|
-
catch {
|
|
324
|
-
process.stdout.write(" ✗ (write failed)\n");
|
|
325
|
-
failed += 1;
|
|
326
|
-
}
|
|
363
|
+
const enriched = tryGhCopilotSuggest(workspaceRoot, relPath, currentContent);
|
|
364
|
+
if (enriched) {
|
|
365
|
+
try {
|
|
366
|
+
writeFileSync(absPath, enriched, "utf-8");
|
|
367
|
+
process.stdout.write(" ✓\n");
|
|
368
|
+
updated += 1;
|
|
327
369
|
}
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
process.stdout.write(" ✗ (empty response)\n");
|
|
370
|
+
catch {
|
|
371
|
+
process.stdout.write(" ✗ (write failed)\n");
|
|
331
372
|
failed += 1;
|
|
332
373
|
}
|
|
333
374
|
}
|
|
334
375
|
else {
|
|
335
|
-
|
|
376
|
+
// Copilot returned empty/trivial output — not a hard failure.
|
|
377
|
+
// The Python AST enricher will handle these in the fallback pass.
|
|
378
|
+
process.stdout.write(" ✗ (empty response)\n");
|
|
336
379
|
failed += 1;
|
|
337
380
|
}
|
|
338
381
|
}
|
|
@@ -501,21 +544,71 @@ program
|
|
|
501
544
|
if (allContextFiles.length === 0) {
|
|
502
545
|
console.log(chalk.yellow(" ⚠ No context.md files found for Copilot enrichment"));
|
|
503
546
|
}
|
|
504
|
-
else if (!isCopilotCliAvailable()) {
|
|
505
|
-
console.log(chalk.yellow(" ⚠ Copilot CLI not installed; skipping Copilot enrichment"));
|
|
506
|
-
}
|
|
507
|
-
else if (!ensureCopilotLogin(workspaceRoot)) {
|
|
508
|
-
console.log(chalk.yellow(" ⚠ Copilot login was not completed; skipping Copilot enrichment"));
|
|
509
|
-
}
|
|
510
547
|
else {
|
|
511
548
|
const cap = Math.min(allContextFiles.length, MAX_ENRICHMENT_FILES);
|
|
512
549
|
if (allContextFiles.length > MAX_ENRICHMENT_FILES) {
|
|
513
550
|
console.log(chalk.gray(` - ${allContextFiles.length} context files found; enriching first ${MAX_ENRICHMENT_FILES} (re-run to continue)`));
|
|
514
551
|
}
|
|
515
|
-
const
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
552
|
+
const copilotVariant = detectCopilotVariant();
|
|
553
|
+
let copilotUpdated = 0;
|
|
554
|
+
let copilotFailed = 0;
|
|
555
|
+
if (!copilotVariant) {
|
|
556
|
+
console.log(chalk.gray(" - GitHub Copilot CLI not found; using Python AST enricher directly"));
|
|
557
|
+
}
|
|
558
|
+
else if (!ensureCopilotLogin(workspaceRoot)) {
|
|
559
|
+
console.log(chalk.yellow(" ⚠ Copilot login was not completed; using Python AST enricher as fallback"));
|
|
560
|
+
}
|
|
561
|
+
else {
|
|
562
|
+
// Primary strategy: Copilot CLI
|
|
563
|
+
const run = enrichWithCopilotCli(workspaceRoot, allContextFiles);
|
|
564
|
+
copilotUpdated = run.updated;
|
|
565
|
+
copilotFailed = run.failed;
|
|
566
|
+
if (copilotUpdated > 0) {
|
|
567
|
+
console.log(chalk.green(` ✓ Copilot updated ${copilotUpdated}/${cap} context.md file(s)`));
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
// Fallback / supplementary: Python AST enricher.
|
|
571
|
+
// Runs when Copilot is unavailable OR Copilot returned empty responses.
|
|
572
|
+
// The Python enricher does not require Copilot — it uses AST analysis
|
|
573
|
+
// and the Azure OpenAI key already configured in the project.
|
|
574
|
+
const needsFallback = copilotFailed > 0 || copilotUpdated === 0;
|
|
575
|
+
if (needsFallback) {
|
|
576
|
+
const skippedFiles = allContextFiles
|
|
577
|
+
.slice(0, MAX_ENRICHMENT_FILES)
|
|
578
|
+
.filter((f) => {
|
|
579
|
+
// Only send files that Copilot did NOT update (i.e. the ones that failed).
|
|
580
|
+
// We approximate this as: all files when copilotUpdated=0,
|
|
581
|
+
// or the trailing copilotFailed files otherwise.
|
|
582
|
+
return true; // enrich_framework_context is idempotent — safe to re-enrich
|
|
583
|
+
});
|
|
584
|
+
if (skippedFiles.length > 0) {
|
|
585
|
+
if (copilotVariant) {
|
|
586
|
+
console.log(chalk.gray(` - Falling back to Python AST enricher for ${copilotFailed} file(s) Copilot missed…`));
|
|
587
|
+
}
|
|
588
|
+
else {
|
|
589
|
+
console.log(chalk.gray(` - Enriching ${Math.min(skippedFiles.length, MAX_ENRICHMENT_FILES)} context.md file(s) via Python AST enricher…`));
|
|
590
|
+
}
|
|
591
|
+
try {
|
|
592
|
+
const enrichResult = await runWorkflow("enrich_framework_context", {
|
|
593
|
+
repo_path: workspaceRoot,
|
|
594
|
+
context_paths: skippedFiles.slice(0, MAX_ENRICHMENT_FILES),
|
|
595
|
+
});
|
|
596
|
+
const pyUpdated = Number(enrichResult.updated ?? 0);
|
|
597
|
+
const pyFailed = Number(enrichResult.failed ?? 0);
|
|
598
|
+
if (pyUpdated > 0) {
|
|
599
|
+
console.log(chalk.green(` ✓ Python AST enricher updated ${pyUpdated} context.md file(s)`));
|
|
600
|
+
}
|
|
601
|
+
if (pyFailed > 0) {
|
|
602
|
+
console.log(chalk.yellow(` ⚠ Python AST enricher could not update ${pyFailed} file(s) (DB may be offline — files are still readable)`));
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
catch (pyErr) {
|
|
606
|
+
// Python enricher failed (likely DB offline) — not fatal.
|
|
607
|
+
// Context files still have the scaffolded template content.
|
|
608
|
+
console.log(chalk.yellow(` ⚠ Python enricher skipped: ${pyErr instanceof Error ? pyErr.message : String(pyErr)}`));
|
|
609
|
+
console.log(chalk.gray(" - Context files contain template scaffolding; enrichment can be re-run after DB is configured."));
|
|
610
|
+
}
|
|
611
|
+
}
|
|
519
612
|
}
|
|
520
613
|
}
|
|
521
614
|
}
|
|
@@ -698,6 +791,22 @@ program
|
|
|
698
791
|
console.log(JSON.stringify(result, null, 2));
|
|
699
792
|
}
|
|
700
793
|
else {
|
|
794
|
+
// DB-offline / degraded state — return a helpful message rather than nothing
|
|
795
|
+
if (result.db_offline || result.error) {
|
|
796
|
+
const msg = String(result.message || result.error || "Unknown error");
|
|
797
|
+
if (result.db_offline) {
|
|
798
|
+
console.log(chalk.yellow("\n⚠ Database not reachable"));
|
|
799
|
+
console.log(chalk.gray(` ${msg}`));
|
|
800
|
+
console.log(chalk.gray("\n Checklist:"));
|
|
801
|
+
console.log(chalk.gray(" 1. Ensure PostgreSQL is running on 127.0.0.1:5432"));
|
|
802
|
+
console.log(chalk.gray(" 2. Set DB_PASSWORD (and optionally DB_USER, DB_NAME) in your .env or shell"));
|
|
803
|
+
console.log(chalk.gray(" 3. Re-run: igel-qe init"));
|
|
804
|
+
}
|
|
805
|
+
else {
|
|
806
|
+
console.log(chalk.yellow(`\n⚠ Status unavailable: ${msg}`));
|
|
807
|
+
}
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
701
810
|
const state = result;
|
|
702
811
|
console.log(chalk.bold("\nProject State"));
|
|
703
812
|
const ps = state.project_state;
|
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -231,40 +231,75 @@ function listFrameworkContextFiles(workspaceRoot: string): string[] {
|
|
|
231
231
|
return Array.from(new Set(found)).sort();
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
234
|
+
// ── Copilot CLI detection ──────────────────────────────────────────────────────
|
|
235
|
+
// Supports two CLI variants:
|
|
236
|
+
// 1. Modern: `gh copilot suggest "<prompt>"` (gh extension, current standard)
|
|
237
|
+
// 2. Legacy: `copilot --version` (@githubnext/github-copilot-cli, deprecated)
|
|
238
|
+
// Detection tries `gh copilot` first; falls back to bare `copilot` binary.
|
|
239
|
+
|
|
240
|
+
type CopilotVariant = "gh" | "legacy" | null;
|
|
241
|
+
|
|
242
|
+
function detectCopilotVariant(): CopilotVariant {
|
|
243
|
+
// Try `gh copilot --version` first (modern, official gh extension)
|
|
244
|
+
const ghCheck = spawnSync("gh", ["copilot", "--version"], {
|
|
237
245
|
encoding: "utf-8",
|
|
238
246
|
shell: process.platform === "win32",
|
|
239
|
-
timeout:
|
|
247
|
+
timeout: 8_000,
|
|
248
|
+
});
|
|
249
|
+
if (ghCheck.status === 0 && !ghCheck.error) return "gh";
|
|
250
|
+
|
|
251
|
+
// Fallback: legacy standalone `copilot` binary
|
|
252
|
+
const legacyCheck = spawnSync("copilot", ["--version"], {
|
|
253
|
+
encoding: "utf-8",
|
|
254
|
+
shell: process.platform === "win32",
|
|
255
|
+
timeout: 8_000,
|
|
240
256
|
});
|
|
241
|
-
|
|
257
|
+
if (legacyCheck.status === 0 && !legacyCheck.error) return "legacy";
|
|
258
|
+
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function isCopilotCliAvailable(): boolean {
|
|
263
|
+
return detectCopilotVariant() !== null;
|
|
242
264
|
}
|
|
243
265
|
|
|
244
266
|
function isCopilotAuthenticated(): boolean {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
["auth", "status"],
|
|
251
|
-
{
|
|
267
|
+
const variant = detectCopilotVariant();
|
|
268
|
+
if (!variant) return false;
|
|
269
|
+
|
|
270
|
+
if (variant === "gh") {
|
|
271
|
+
// `gh auth status` exits 0 when authenticated
|
|
272
|
+
const probe = spawnSync("gh", ["auth", "status"], {
|
|
252
273
|
encoding: "utf-8",
|
|
253
274
|
shell: process.platform === "win32",
|
|
254
|
-
timeout: 10_000,
|
|
255
|
-
}
|
|
256
|
-
|
|
275
|
+
timeout: 10_000,
|
|
276
|
+
});
|
|
277
|
+
return probe.status === 0 && !probe.error;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Legacy variant — try `copilot auth status`
|
|
281
|
+
const probe = spawnSync("copilot", ["auth", "status"], {
|
|
282
|
+
encoding: "utf-8",
|
|
283
|
+
shell: process.platform === "win32",
|
|
284
|
+
timeout: 10_000,
|
|
285
|
+
});
|
|
257
286
|
return probe.status === 0 && !probe.error;
|
|
258
287
|
}
|
|
259
288
|
|
|
260
289
|
function ensureCopilotLogin(workspaceRoot: string): boolean {
|
|
261
|
-
|
|
290
|
+
const variant = detectCopilotVariant();
|
|
291
|
+
if (!variant) return false;
|
|
262
292
|
if (isCopilotAuthenticated()) return true;
|
|
263
293
|
|
|
264
294
|
console.log(chalk.yellow(" ⚠ GitHub Copilot CLI is not authenticated."));
|
|
265
295
|
console.log(chalk.yellow(" → Please complete Copilot login to continue context enrichment."));
|
|
266
296
|
|
|
267
|
-
const
|
|
297
|
+
const loginCmd = variant === "gh" ? ["gh", "auth", "login"] : ["copilot", "login"];
|
|
298
|
+
const login = spawnSync(loginCmd[0], loginCmd.slice(1), {
|
|
299
|
+
stdio: "inherit",
|
|
300
|
+
encoding: "utf-8",
|
|
301
|
+
shell: process.platform === "win32",
|
|
302
|
+
});
|
|
268
303
|
if (login.status !== 0) return false;
|
|
269
304
|
return isCopilotAuthenticated();
|
|
270
305
|
}
|
|
@@ -273,6 +308,72 @@ function ensureCopilotLogin(workspaceRoot: string): boolean {
|
|
|
273
308
|
// Prevents extremely long waits on repos with many folders.
|
|
274
309
|
const MAX_ENRICHMENT_FILES = 15;
|
|
275
310
|
|
|
311
|
+
/**
|
|
312
|
+
* Attempt to enrich a single context.md file using `gh copilot suggest`.
|
|
313
|
+
* Returns the enriched text, or null on failure/empty response.
|
|
314
|
+
*/
|
|
315
|
+
function tryGhCopilotSuggest(
|
|
316
|
+
workspaceRoot: string,
|
|
317
|
+
relPath: string,
|
|
318
|
+
currentContent: string,
|
|
319
|
+
): string | null {
|
|
320
|
+
const prompt = [
|
|
321
|
+
`You are enriching a context.md file for an automation test framework.`,
|
|
322
|
+
`The file is located at: ${relPath} (relative to the workspace root).`,
|
|
323
|
+
``,
|
|
324
|
+
`Current content:`,
|
|
325
|
+
`\`\`\`markdown`,
|
|
326
|
+
currentContent,
|
|
327
|
+
`\`\`\``,
|
|
328
|
+
``,
|
|
329
|
+
`Rewrite the content above to be concrete, human-understandable, and LLM-ready.`,
|
|
330
|
+
`Requirements:`,
|
|
331
|
+
`- Keep the same heading structure (# Context, ## Purpose, ## Key Assets, ## Conventions, ## Risks and Dependencies).`,
|
|
332
|
+
`- Replace placeholder text with specific details inferred from the folder path and framework context.`,
|
|
333
|
+
`- Be concise but informative. Each section: 2–5 bullet points or short sentences.`,
|
|
334
|
+
`- Output ONLY the improved markdown. Do NOT add commentary, code fences, or extra text around it.`,
|
|
335
|
+
].join("\n");
|
|
336
|
+
|
|
337
|
+
const variant = detectCopilotVariant();
|
|
338
|
+
|
|
339
|
+
let result;
|
|
340
|
+
if (variant === "gh") {
|
|
341
|
+
// Modern: `gh copilot suggest -t shell "<prompt>"`
|
|
342
|
+
// We use --no-color so output can be parsed cleanly.
|
|
343
|
+
result = spawnSync(
|
|
344
|
+
"gh",
|
|
345
|
+
["copilot", "suggest", "-t", "shell", "--", prompt],
|
|
346
|
+
{
|
|
347
|
+
cwd: workspaceRoot,
|
|
348
|
+
encoding: "utf-8",
|
|
349
|
+
shell: process.platform === "win32",
|
|
350
|
+
timeout: 120_000,
|
|
351
|
+
input: "\n", // auto-accept any interactive prompt
|
|
352
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
353
|
+
},
|
|
354
|
+
);
|
|
355
|
+
} else if (variant === "legacy") {
|
|
356
|
+
// Legacy @githubnext/github-copilot-cli binary
|
|
357
|
+
result = spawnSync(
|
|
358
|
+
"copilot",
|
|
359
|
+
["-C", workspaceRoot, "-p", prompt, "--no-color", "-s"],
|
|
360
|
+
{
|
|
361
|
+
encoding: "utf-8",
|
|
362
|
+
shell: process.platform === "win32",
|
|
363
|
+
timeout: 120_000,
|
|
364
|
+
input: "",
|
|
365
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
366
|
+
},
|
|
367
|
+
);
|
|
368
|
+
} else {
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (!result || result.status !== 0 || result.error) return null;
|
|
373
|
+
const text = (result.stdout || "").trim();
|
|
374
|
+
return text.length > 50 ? text : null;
|
|
375
|
+
}
|
|
376
|
+
|
|
276
377
|
function enrichWithCopilotCli(
|
|
277
378
|
workspaceRoot: string,
|
|
278
379
|
contextFiles: string[],
|
|
@@ -292,75 +393,31 @@ function enrichWithCopilotCli(
|
|
|
292
393
|
.replace(`${workspaceRoot}/`, "")
|
|
293
394
|
.replace(/\\/g, "/");
|
|
294
395
|
|
|
295
|
-
// Show per-file progress so the user knows work is happening.
|
|
296
396
|
process.stdout.write(` [${i + 1}/${total}] Enriching ${relPath}…`);
|
|
297
397
|
|
|
298
|
-
// Read the current (template) content so Copilot can see the structure.
|
|
299
398
|
let currentContent = "";
|
|
300
399
|
try {
|
|
301
400
|
currentContent = readFileSync(absPath, "utf-8");
|
|
302
401
|
} catch {
|
|
303
|
-
// If we can't read, skip this file.
|
|
304
402
|
process.stdout.write(" ✗ (unreadable)\n");
|
|
305
403
|
failed += 1;
|
|
306
404
|
continue;
|
|
307
405
|
}
|
|
308
406
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
currentContent,
|
|
318
|
-
`\`\`\``,
|
|
319
|
-
``,
|
|
320
|
-
`Rewrite the content above to be concrete, human-understandable, and LLM-ready.`,
|
|
321
|
-
`Requirements:`,
|
|
322
|
-
`- Keep the same heading structure (# Context, ## Purpose, ## Key Assets, ## Conventions, ## Risks and Dependencies).`,
|
|
323
|
-
`- Replace placeholder text with specific details inferred from the folder path and framework context.`,
|
|
324
|
-
`- Be concise but informative. Each section: 2–5 bullet points or short sentences.`,
|
|
325
|
-
`- Output ONLY the improved markdown. Do NOT add commentary, code fences, or extra text around it.`,
|
|
326
|
-
].join("\n");
|
|
327
|
-
|
|
328
|
-
const result = spawnSync(
|
|
329
|
-
"copilot",
|
|
330
|
-
[
|
|
331
|
-
"-C", workspaceRoot,
|
|
332
|
-
"-p", prompt,
|
|
333
|
-
"--no-color",
|
|
334
|
-
"-s",
|
|
335
|
-
],
|
|
336
|
-
{
|
|
337
|
-
encoding: "utf-8",
|
|
338
|
-
shell: process.platform === "win32",
|
|
339
|
-
timeout: 120_000, // 2 min per file — prevents indefinite hangs
|
|
340
|
-
input: "", // close stdin immediately
|
|
341
|
-
maxBuffer: 4 * 1024 * 1024, // 4 MB stdout buffer
|
|
342
|
-
},
|
|
343
|
-
);
|
|
344
|
-
|
|
345
|
-
if (result.status === 0 && !result.error) {
|
|
346
|
-
const enriched = (result.stdout || "").trim();
|
|
347
|
-
if (enriched.length > 50) {
|
|
348
|
-
// Only write back if Copilot returned meaningful content (>50 chars).
|
|
349
|
-
try {
|
|
350
|
-
writeFileSync(absPath, enriched, "utf-8");
|
|
351
|
-
process.stdout.write(" ✓\n");
|
|
352
|
-
updated += 1;
|
|
353
|
-
} catch {
|
|
354
|
-
process.stdout.write(" ✗ (write failed)\n");
|
|
355
|
-
failed += 1;
|
|
356
|
-
}
|
|
357
|
-
} else {
|
|
358
|
-
// Copilot ran but returned empty/trivial output.
|
|
359
|
-
process.stdout.write(" ✗ (empty response)\n");
|
|
407
|
+
const enriched = tryGhCopilotSuggest(workspaceRoot, relPath, currentContent);
|
|
408
|
+
if (enriched) {
|
|
409
|
+
try {
|
|
410
|
+
writeFileSync(absPath, enriched, "utf-8");
|
|
411
|
+
process.stdout.write(" ✓\n");
|
|
412
|
+
updated += 1;
|
|
413
|
+
} catch {
|
|
414
|
+
process.stdout.write(" ✗ (write failed)\n");
|
|
360
415
|
failed += 1;
|
|
361
416
|
}
|
|
362
417
|
} else {
|
|
363
|
-
|
|
418
|
+
// Copilot returned empty/trivial output — not a hard failure.
|
|
419
|
+
// The Python AST enricher will handle these in the fallback pass.
|
|
420
|
+
process.stdout.write(" ✗ (empty response)\n");
|
|
364
421
|
failed += 1;
|
|
365
422
|
}
|
|
366
423
|
}
|
|
@@ -535,19 +592,70 @@ program
|
|
|
535
592
|
const allContextFiles = listFrameworkContextFiles(workspaceRoot);
|
|
536
593
|
if (allContextFiles.length === 0) {
|
|
537
594
|
console.log(chalk.yellow(" ⚠ No context.md files found for Copilot enrichment"));
|
|
538
|
-
} else if (!isCopilotCliAvailable()) {
|
|
539
|
-
console.log(chalk.yellow(" ⚠ Copilot CLI not installed; skipping Copilot enrichment"));
|
|
540
|
-
} else if (!ensureCopilotLogin(workspaceRoot)) {
|
|
541
|
-
console.log(chalk.yellow(" ⚠ Copilot login was not completed; skipping Copilot enrichment"));
|
|
542
595
|
} else {
|
|
543
596
|
const cap = Math.min(allContextFiles.length, MAX_ENRICHMENT_FILES);
|
|
544
597
|
if (allContextFiles.length > MAX_ENRICHMENT_FILES) {
|
|
545
598
|
console.log(chalk.gray(` - ${allContextFiles.length} context files found; enriching first ${MAX_ENRICHMENT_FILES} (re-run to continue)`));
|
|
546
599
|
}
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
600
|
+
|
|
601
|
+
const copilotVariant = detectCopilotVariant();
|
|
602
|
+
let copilotUpdated = 0;
|
|
603
|
+
let copilotFailed = 0;
|
|
604
|
+
|
|
605
|
+
if (!copilotVariant) {
|
|
606
|
+
console.log(chalk.gray(" - GitHub Copilot CLI not found; using Python AST enricher directly"));
|
|
607
|
+
} else if (!ensureCopilotLogin(workspaceRoot)) {
|
|
608
|
+
console.log(chalk.yellow(" ⚠ Copilot login was not completed; using Python AST enricher as fallback"));
|
|
609
|
+
} else {
|
|
610
|
+
// Primary strategy: Copilot CLI
|
|
611
|
+
const run = enrichWithCopilotCli(workspaceRoot, allContextFiles);
|
|
612
|
+
copilotUpdated = run.updated;
|
|
613
|
+
copilotFailed = run.failed;
|
|
614
|
+
if (copilotUpdated > 0) {
|
|
615
|
+
console.log(chalk.green(` ✓ Copilot updated ${copilotUpdated}/${cap} context.md file(s)`));
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Fallback / supplementary: Python AST enricher.
|
|
620
|
+
// Runs when Copilot is unavailable OR Copilot returned empty responses.
|
|
621
|
+
// The Python enricher does not require Copilot — it uses AST analysis
|
|
622
|
+
// and the Azure OpenAI key already configured in the project.
|
|
623
|
+
const needsFallback = copilotFailed > 0 || copilotUpdated === 0;
|
|
624
|
+
if (needsFallback) {
|
|
625
|
+
const skippedFiles = allContextFiles
|
|
626
|
+
.slice(0, MAX_ENRICHMENT_FILES)
|
|
627
|
+
.filter((f) => {
|
|
628
|
+
// Only send files that Copilot did NOT update (i.e. the ones that failed).
|
|
629
|
+
// We approximate this as: all files when copilotUpdated=0,
|
|
630
|
+
// or the trailing copilotFailed files otherwise.
|
|
631
|
+
return true; // enrich_framework_context is idempotent — safe to re-enrich
|
|
632
|
+
});
|
|
633
|
+
if (skippedFiles.length > 0) {
|
|
634
|
+
if (copilotVariant) {
|
|
635
|
+
console.log(chalk.gray(` - Falling back to Python AST enricher for ${copilotFailed} file(s) Copilot missed…`));
|
|
636
|
+
} else {
|
|
637
|
+
console.log(chalk.gray(` - Enriching ${Math.min(skippedFiles.length, MAX_ENRICHMENT_FILES)} context.md file(s) via Python AST enricher…`));
|
|
638
|
+
}
|
|
639
|
+
try {
|
|
640
|
+
const enrichResult = await runWorkflow("enrich_framework_context", {
|
|
641
|
+
repo_path: workspaceRoot,
|
|
642
|
+
context_paths: skippedFiles.slice(0, MAX_ENRICHMENT_FILES),
|
|
643
|
+
}) as Record<string, unknown>;
|
|
644
|
+
const pyUpdated = Number(enrichResult.updated ?? 0);
|
|
645
|
+
const pyFailed = Number(enrichResult.failed ?? 0);
|
|
646
|
+
if (pyUpdated > 0) {
|
|
647
|
+
console.log(chalk.green(` ✓ Python AST enricher updated ${pyUpdated} context.md file(s)`));
|
|
648
|
+
}
|
|
649
|
+
if (pyFailed > 0) {
|
|
650
|
+
console.log(chalk.yellow(` ⚠ Python AST enricher could not update ${pyFailed} file(s) (DB may be offline — files are still readable)`));
|
|
651
|
+
}
|
|
652
|
+
} catch (pyErr: unknown) {
|
|
653
|
+
// Python enricher failed (likely DB offline) — not fatal.
|
|
654
|
+
// Context files still have the scaffolded template content.
|
|
655
|
+
console.log(chalk.yellow(` ⚠ Python enricher skipped: ${pyErr instanceof Error ? pyErr.message : String(pyErr)}`) );
|
|
656
|
+
console.log(chalk.gray(" - Context files contain template scaffolding; enrichment can be re-run after DB is configured."));
|
|
657
|
+
}
|
|
658
|
+
}
|
|
551
659
|
}
|
|
552
660
|
}
|
|
553
661
|
} catch (e: unknown) {
|
|
@@ -733,6 +841,22 @@ program
|
|
|
733
841
|
if (options.json) {
|
|
734
842
|
console.log(JSON.stringify(result, null, 2));
|
|
735
843
|
} else {
|
|
844
|
+
// DB-offline / degraded state — return a helpful message rather than nothing
|
|
845
|
+
if (result.db_offline || result.error) {
|
|
846
|
+
const msg = String(result.message || result.error || "Unknown error");
|
|
847
|
+
if (result.db_offline) {
|
|
848
|
+
console.log(chalk.yellow("\n⚠ Database not reachable"));
|
|
849
|
+
console.log(chalk.gray(` ${msg}`));
|
|
850
|
+
console.log(chalk.gray("\n Checklist:"));
|
|
851
|
+
console.log(chalk.gray(" 1. Ensure PostgreSQL is running on 127.0.0.1:5432"));
|
|
852
|
+
console.log(chalk.gray(" 2. Set DB_PASSWORD (and optionally DB_USER, DB_NAME) in your .env or shell"));
|
|
853
|
+
console.log(chalk.gray(" 3. Re-run: igel-qe init"));
|
|
854
|
+
} else {
|
|
855
|
+
console.log(chalk.yellow(`\n⚠ Status unavailable: ${msg}`));
|
|
856
|
+
}
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
|
|
736
860
|
const state = (result as Record<string, unknown>);
|
|
737
861
|
console.log(chalk.bold("\nProject State"));
|
|
738
862
|
const ps = state.project_state as Record<string, number> | undefined;
|
package/src/cli/workflow_cli.py
CHANGED
|
@@ -136,7 +136,16 @@ def main():
|
|
|
136
136
|
_err(_DEPS_ERROR or "Required Python packages not installed. Run: igel-qe init --with-copilot")
|
|
137
137
|
|
|
138
138
|
# ── DB init ────────────────────────────────────────────────────────────────
|
|
139
|
-
|
|
139
|
+
# Skip for init_check / init_project: those actions run run_checks() which
|
|
140
|
+
# already tests DB connectivity and reports a structured "postgres: fail"
|
|
141
|
+
# result instead of a hard crash. Calling ensure_igel_tables() here first
|
|
142
|
+
# would cause a non-zero exit before run_checks() ever runs, swallowing
|
|
143
|
+
# the structured error output that the Node CLI depends on.
|
|
144
|
+
# Skip for get_project_status: it reads from DB internally and returns a
|
|
145
|
+
# graceful "db_offline" response on failure — no schema init needed.
|
|
146
|
+
# Also skip for submit_feedback which manages its own connection.
|
|
147
|
+
_DB_SKIP_ACTIONS = ("submit_feedback", "init_check", "init_project", "get_project_status")
|
|
148
|
+
if args.action not in _DB_SKIP_ACTIONS:
|
|
140
149
|
try:
|
|
141
150
|
ensure_igel_tables()
|
|
142
151
|
except Exception as e:
|
|
@@ -806,7 +815,9 @@ def main():
|
|
|
806
815
|
projection = _best_effort_project_context(repo_path)
|
|
807
816
|
|
|
808
817
|
_ok({
|
|
809
|
-
"
|
|
818
|
+
"updated": len(enriched), # TS reads this for progress display
|
|
819
|
+
"failed": 0, # AST enricher does not partially fail
|
|
820
|
+
"enriched_count": len(enriched), # backward compat alias
|
|
810
821
|
"enriched": [
|
|
811
822
|
{
|
|
812
823
|
"path": r.path,
|
|
@@ -823,7 +834,14 @@ def main():
|
|
|
823
834
|
"context_projection": projection,
|
|
824
835
|
})
|
|
825
836
|
except Exception as e:
|
|
826
|
-
|
|
837
|
+
# Return a partial-success response so TS can show a warning instead of crashing.
|
|
838
|
+
# Common failure: DB offline (context files are still written to disk).
|
|
839
|
+
_ok({
|
|
840
|
+
"updated": 0,
|
|
841
|
+
"failed": -1, # signals error condition to TS
|
|
842
|
+
"error": str(e),
|
|
843
|
+
"enriched_count": 0,
|
|
844
|
+
})
|
|
827
845
|
|
|
828
846
|
# ── update_context_file ─────────────────────────────────────────────────
|
|
829
847
|
# Accepts: {
|
|
@@ -968,9 +986,21 @@ def main():
|
|
|
968
986
|
summary = rebuild_system_state_from_db(root)
|
|
969
987
|
_ok(summary)
|
|
970
988
|
except Exception as e:
|
|
971
|
-
|
|
989
|
+
# Return graceful degraded status so `igel-qe status` doesn't crash
|
|
990
|
+
# when DB is offline — users should still get a useful message.
|
|
991
|
+
err_msg = str(e)
|
|
992
|
+
db_offline = "password" in err_msg.lower() or "connection" in err_msg.lower() or "psycopg" in err_msg.lower()
|
|
993
|
+
_ok({
|
|
994
|
+
"db_offline": db_offline,
|
|
995
|
+
"error": err_msg,
|
|
996
|
+
"project_state": None,
|
|
997
|
+
"sync_state": None,
|
|
998
|
+
"message": (
|
|
999
|
+
"Database is offline or not configured. "
|
|
1000
|
+
"Set DB_PASSWORD / DATABASE_URL in your environment and re-run igel-qe init."
|
|
1001
|
+
) if db_offline else err_msg,
|
|
1002
|
+
})
|
|
972
1003
|
|
|
973
1004
|
|
|
974
1005
|
if __name__ == "__main__":
|
|
975
1006
|
main()
|
|
976
|
-
|