ado-sync 0.1.47 → 0.1.49

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 (52) hide show
  1. package/.next-steps.md +179 -0
  2. package/README.md +13 -0
  3. package/dist/ai/summarizer.js +6 -3
  4. package/dist/ai/summarizer.js.map +1 -1
  5. package/dist/azure/test-cases.js +28 -10
  6. package/dist/azure/test-cases.js.map +1 -1
  7. package/dist/azure/work-items.d.ts +28 -0
  8. package/dist/azure/work-items.js +96 -0
  9. package/dist/azure/work-items.js.map +1 -1
  10. package/dist/cli.js +362 -25
  11. package/dist/cli.js.map +1 -1
  12. package/dist/config.d.ts +0 -5
  13. package/dist/config.js +17 -3
  14. package/dist/config.js.map +1 -1
  15. package/dist/issues/ado-bugs.d.ts +23 -0
  16. package/dist/issues/ado-bugs.js +59 -0
  17. package/dist/issues/ado-bugs.js.map +1 -0
  18. package/dist/issues/create-issues.d.ts +32 -0
  19. package/dist/issues/create-issues.js +236 -0
  20. package/dist/issues/create-issues.js.map +1 -0
  21. package/dist/issues/github.d.ts +22 -0
  22. package/dist/issues/github.js +95 -0
  23. package/dist/issues/github.js.map +1 -0
  24. package/dist/mcp-server.d.ts +3 -0
  25. package/dist/mcp-server.js +154 -20
  26. package/dist/mcp-server.js.map +1 -1
  27. package/dist/parsers/gherkin.js +2 -1
  28. package/dist/parsers/gherkin.js.map +1 -1
  29. package/dist/sync/engine.d.ts +41 -0
  30. package/dist/sync/engine.js +176 -10
  31. package/dist/sync/engine.js.map +1 -1
  32. package/dist/sync/manifest.d.ts +69 -0
  33. package/dist/sync/manifest.js +197 -0
  34. package/dist/sync/manifest.js.map +1 -0
  35. package/dist/sync/publish-results.d.ts +8 -1
  36. package/dist/sync/publish-results.js +167 -5
  37. package/dist/sync/publish-results.js.map +1 -1
  38. package/dist/sync/writeback.js +41 -20
  39. package/dist/sync/writeback.js.map +1 -1
  40. package/dist/types.d.ts +53 -0
  41. package/docs/mcp-server.md +131 -29
  42. package/docs/publish-test-results.md +136 -2
  43. package/docs/vscode-extension.md +139 -0
  44. package/llms.txt +28 -3
  45. package/package.json +2 -2
  46. package/.cucumber/.ado-sync-state.json +0 -282
  47. package/.cucumber/ado-sync.yaml +0 -21
  48. package/.cucumber/features/cart.feature +0 -62
  49. package/.cucumber/features/checkout.feature +0 -100
  50. package/.cucumber/features/homepage.feature +0 -7
  51. package/.cucumber/features/inventory.feature +0 -59
  52. package/.cucumber/features/login.feature +0 -74
package/dist/cli.js CHANGED
@@ -37,15 +37,14 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
37
37
  return (mod && mod.__esModule) ? mod : { "default": mod };
38
38
  };
39
39
  Object.defineProperty(exports, "__esModule", { value: true });
40
- // Suppress the url.parse() deprecation warning emitted by azure-devops-node-api internals.
41
- // This is DEP0169 and cannot be fixed on our side.
42
- const originalEmit = process.emit;
43
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
44
- process.emit = function (event, ...args) {
45
- if (event === 'warning' && args[0]?.code === 'DEP0169')
46
- return false;
47
- return originalEmit.apply(process, [event, ...args]);
48
- };
40
+ // Suppress the url.parse() deprecation warning (DEP0169) emitted by azure-devops-node-api
41
+ // internals. Cannot be fixed on our side; use the warning event listener instead of
42
+ // monkey-patching process.emit.
43
+ process.on('warning', (warning) => {
44
+ if (warning.code === 'DEP0169')
45
+ return;
46
+ process.stderr.write(`[node:warning] ${warning.name}: ${warning.message}\n`);
47
+ });
49
48
  require("dotenv/config");
50
49
  const chalk_1 = __importDefault(require("chalk"));
51
50
  const commander_1 = require("commander");
@@ -58,6 +57,7 @@ const client_1 = require("./azure/client");
58
57
  const config_1 = require("./config");
59
58
  const engine_1 = require("./sync/engine");
60
59
  const generate_1 = require("./sync/generate");
60
+ const manifest_1 = require("./sync/manifest");
61
61
  const publish_results_1 = require("./sync/publish-results");
62
62
  // ─── CLI definition ───────────────────────────────────────────────────────────
63
63
  const program = new commander_1.Command();
@@ -69,24 +69,31 @@ program
69
69
  program.option('-c, --config <path>', 'Path to config file (default: ado-sync.json)');
70
70
  program.option('--output <format>', 'Output format: text (default) or json');
71
71
  // ─── Error formatting (L) ─────────────────────────────────────────────────────
72
+ function toError(err) {
73
+ if (err instanceof Error)
74
+ return err;
75
+ return { message: String(err) };
76
+ }
72
77
  function formatError(err) {
73
- const status = err?.statusCode ?? err?.status ?? 0;
78
+ const e = toError(err);
79
+ const status = e.statusCode ?? e.status ?? 0;
74
80
  if (status === 401)
75
81
  return 'Authentication failed. Check your token (auth.token) and permissions.';
76
82
  if (status === 403)
77
83
  return 'Access denied. Your token lacks the required Azure DevOps scopes.';
78
84
  if (status === 404)
79
- return `Resource not found. Check your orgUrl, project, or test plan ID.\n ${err.message}`;
85
+ return `Resource not found. Check your orgUrl, project, or test plan ID.\n ${e.message}`;
80
86
  if (status === 429)
81
87
  return 'Azure DevOps rate limit hit. Reduce concurrent operations or retry later.';
82
88
  if (status === 503)
83
89
  return 'Azure DevOps is temporarily unavailable (503). Retry later.';
84
- return err?.message ?? String(err);
90
+ return e.message;
85
91
  }
86
92
  function handleError(err) {
87
- console.error(chalk_1.default.red(formatError(err)));
93
+ const e = toError(err);
94
+ console.error(chalk_1.default.red(formatError(e)));
88
95
  if (process.env.DEBUG)
89
- console.error(chalk_1.default.dim(err?.stack ?? ''));
96
+ console.error(chalk_1.default.dim(e.stack ?? ''));
90
97
  process.exit(1);
91
98
  }
92
99
  /** Collect repeatable option values into an array. */
@@ -140,7 +147,8 @@ function buildAiOpts(opts, config, configDir) {
140
147
  contextContent = fs.readFileSync(absContextPath, 'utf8');
141
148
  }
142
149
  catch (err) {
143
- process.stderr.write(` [ai-summary] Warning: could not read contextFile "${absContextPath}": ${err.message}\n`);
150
+ const msg = err instanceof Error ? err.message : String(err);
151
+ process.stderr.write(` [ai-summary] Warning: could not read contextFile "${absContextPath}": ${msg}\n`);
144
152
  }
145
153
  }
146
154
  return {
@@ -337,13 +345,19 @@ program
337
345
  // ─── publish-test-results ─────────────────────────────────────────────────────
338
346
  program
339
347
  .command('publish-test-results')
340
- .description('Publish test results from result files (TRX, JUnit, Cucumber JSON) to Azure DevOps')
348
+ .description('Publish test results from result files (TRX, JUnit, Cucumber JSON, CTRF) to Azure DevOps')
341
349
  .option('--testResult <path>', 'Path to a test result file (repeatable)', collect, [])
342
- .option('--testResultFormat <format>', 'Result file format: trx, nunitXml, junit, cucumberJson, playwrightJson')
350
+ .option('--testResultFormat <format>', 'Result file format: trx, nunitXml, junit, cucumberJson, playwrightJson, ctrfJson')
343
351
  .option('--attachmentsFolder <path>', 'Folder with screenshots/videos/logs to attach to test results')
344
352
  .option('--runName <name>', 'Name for the test run in Azure DevOps')
345
353
  .option('--buildId <id>', 'Build ID to associate with the test run')
346
354
  .option('--dry-run', 'Parse results and show summary without publishing')
355
+ .option('--create-issues-on-failure', 'File GitHub Issues or ADO Bugs for each failed test')
356
+ .option('--issue-provider <provider>', 'Issue provider: github (default) or ado')
357
+ .option('--github-repo <owner/repo>', 'GitHub repository to file issues in (e.g. "myorg/myrepo")')
358
+ .option('--github-token <token>', 'GitHub token ($ENV_VAR reference supported)')
359
+ .option('--bug-threshold <percent>', 'Failure % above which one env-failure issue is filed instead of per-test (default: 20)')
360
+ .option('--max-issues <n>', 'Hard cap on issues filed per run (default: 50)')
347
361
  .option('--config-override <path=value>', 'Override a config value (repeatable)', collect, [])
348
362
  .action(async (opts) => {
349
363
  const globalOpts = program.opts();
@@ -365,6 +379,14 @@ program
365
379
  attachmentsFolder: opts.attachmentsFolder,
366
380
  runName: opts.runName,
367
381
  buildId: opts.buildId ? parseInt(opts.buildId) : undefined,
382
+ createIssuesOnFailure: opts.createIssuesOnFailure,
383
+ issueOverrides: {
384
+ ...(opts.issueProvider && { provider: opts.issueProvider }),
385
+ ...(opts.githubRepo && { repo: opts.githubRepo }),
386
+ ...(opts.githubToken && { token: opts.githubToken }),
387
+ ...(opts.bugThreshold && { threshold: parseInt(opts.bugThreshold) }),
388
+ ...(opts.maxIssues && { maxIssues: parseInt(opts.maxIssues) }),
389
+ },
368
390
  });
369
391
  console.log(chalk_1.default.green(`Total results: ${result.totalResults}`));
370
392
  console.log(` ${chalk_1.default.green(`${result.passed} passed`)} ${chalk_1.default.red(`${result.failed} failed`)} ${chalk_1.default.dim(`${result.other} other`)}`);
@@ -372,6 +394,21 @@ program
372
394
  console.log(chalk_1.default.dim(`Run ID: ${result.runId}`));
373
395
  console.log(chalk_1.default.dim(`URL: ${result.runUrl}`));
374
396
  }
397
+ if (result.issuesSummary) {
398
+ const s = result.issuesSummary;
399
+ console.log('');
400
+ console.log(chalk_1.default.bold('Issues filed:'));
401
+ console.log(chalk_1.default.dim(` Mode: ${s.mode} | Total failed: ${s.totalFailed} | Suppressed: ${s.suppressed}`));
402
+ for (const issue of s.issued) {
403
+ const prefix = issue.action === 'created' ? chalk_1.default.green('+')
404
+ : issue.action === 'skipped' ? chalk_1.default.dim('=')
405
+ : chalk_1.default.yellow('~');
406
+ const detail = issue.url ? chalk_1.default.dim(` → ${issue.url}`)
407
+ : issue.reason ? chalk_1.default.dim(` (${issue.reason})`)
408
+ : '';
409
+ console.log(` ${prefix} ${issue.title}${detail}`);
410
+ }
411
+ }
375
412
  }
376
413
  catch (err) {
377
414
  handleError(err);
@@ -509,7 +546,8 @@ program
509
546
  console.log(chalk_1.default.green(' ✓ Config is valid'));
510
547
  }
511
548
  catch (err) {
512
- console.log(chalk_1.default.red(` ✗ Config error: ${err.message}`));
549
+ const msg = err instanceof Error ? err.message : String(err);
550
+ console.log(chalk_1.default.red(` ✗ Config error: ${msg}`));
513
551
  process.exit(1);
514
552
  }
515
553
  // 2. Connect to Azure
@@ -559,6 +597,8 @@ program
559
597
  .description('Show field-level diff between local specs and Azure DevOps')
560
598
  .option('--tags <expression>', 'Only check scenarios matching this tag expression')
561
599
  .option('--config-override <path=value>', 'Override a config value (repeatable)', collect, [])
600
+ .option('--format <fmt>', 'Output format: text (default) or json')
601
+ .option('--fail-on-drift', 'Exit with code 1 when any differences are found (useful as a CI quality gate)')
562
602
  .option('--ai-provider <provider>', 'AI provider for test step generation')
563
603
  .option('--ai-model <model>', 'AI model path or name')
564
604
  .option('--ai-url <url>', 'Base URL for AI endpoint')
@@ -578,9 +618,22 @@ program
578
618
  console.log('');
579
619
  const aiSummary = buildAiOpts(opts, config, configDir);
580
620
  const results = await (0, engine_1.status)(config, configDir, { tags: opts.tags, aiSummary });
581
- const outputFormat = program.opts().output;
621
+ // --format json or global --output json
622
+ const outputFormat = opts.format ?? program.opts().output;
582
623
  if (outputFormat === 'json') {
583
- process.stdout.write(JSON.stringify(results, null, 2) + '\n');
624
+ const diffs = results
625
+ .filter((r) => r.action === 'updated' || r.action === 'conflict')
626
+ .map((r) => ({
627
+ action: r.action,
628
+ azureId: r.azureId,
629
+ title: r.title,
630
+ filePath: r.filePath ? path.relative(process.cwd(), r.filePath) : '',
631
+ changedFields: r.changedFields ?? [],
632
+ diffDetail: r.diffDetail ?? [],
633
+ }));
634
+ process.stdout.write(JSON.stringify({ total: results.length, diffs }, null, 2) + '\n');
635
+ if (opts.failOnDrift && diffs.length > 0)
636
+ process.exit(1);
584
637
  return;
585
638
  }
586
639
  let anyDiff = false;
@@ -595,12 +648,23 @@ program
595
648
  if (r.changedFields?.length) {
596
649
  console.log(chalk_1.default.dim(` changed fields: ${r.changedFields.join(', ')}`));
597
650
  }
651
+ if (r.diffDetail?.length) {
652
+ for (const d of r.diffDetail) {
653
+ const local = String(d.local).slice(0, 80).replace(/\n/g, '↵');
654
+ const remote = String(d.remote).slice(0, 80).replace(/\n/g, '↵');
655
+ console.log(chalk_1.default.dim(` ${d.field}:`));
656
+ console.log(chalk_1.default.green(` local: ${local}`));
657
+ console.log(chalk_1.default.red(` remote: ${remote}`));
658
+ }
659
+ }
598
660
  else if (r.detail) {
599
661
  console.log(chalk_1.default.dim(` ${r.detail}`));
600
662
  }
601
663
  }
602
664
  if (!anyDiff)
603
665
  console.log(chalk_1.default.dim('No differences found.'));
666
+ if (opts.failOnDrift && anyDiff)
667
+ process.exit(1);
604
668
  }
605
669
  catch (err) {
606
670
  handleError(err);
@@ -609,12 +673,13 @@ program
609
673
  // ─── generate ────────────────────────────────────────────────────────────────
610
674
  program
611
675
  .command('generate')
612
- .description('Generate local spec files from Azure DevOps User Stories')
676
+ .description('Generate local spec files (or AI workflow manifests) from Azure DevOps User Stories')
613
677
  .option('--story-ids <ids>', 'Comma-separated ADO work item IDs (e.g. 1234,5678)')
614
678
  .option('--query <wiql>', 'WIQL query string to select stories')
615
679
  .option('--area-path <path>', 'Filter by area path')
616
680
  .option('--format <fmt>', 'Output format: gherkin or markdown (default: markdown)')
617
681
  .option('--output-folder <dir>', 'Where to write files (default: config pull.targetFolder or .)')
682
+ .option('--manifest', 'Generate .ai-workflow-manifest-{id}.json instead of spec files')
618
683
  .option('--force', 'Overwrite existing files')
619
684
  .option('--dry-run', 'Show what would be created without writing files')
620
685
  .option('--config-override <path=value>', 'Override a config value (repeatable)', collect, [])
@@ -626,15 +691,55 @@ program
626
691
  if (opts.configOverride?.length)
627
692
  (0, config_1.applyOverrides)(config, opts.configOverride);
628
693
  const configDir = path.dirname(configPath);
694
+ const outputFormat = globalOpts.output;
695
+ const storyIds = opts.storyIds
696
+ ? opts.storyIds.split(',').map((s) => parseInt(s.trim(), 10)).filter(Boolean)
697
+ : undefined;
698
+ if (opts.manifest) {
699
+ // ── Manifest mode ──────────────────────────────────────────────────────
700
+ if (!storyIds?.length) {
701
+ console.error(chalk_1.default.red('--story-ids is required with --manifest'));
702
+ process.exit(1);
703
+ }
704
+ console.log(chalk_1.default.bold('ado-sync generate --manifest'));
705
+ console.log(chalk_1.default.dim(`Config: ${configPath}`));
706
+ if (opts.dryRun)
707
+ console.log(chalk_1.default.yellow('Dry run — no files will be written'));
708
+ console.log('');
709
+ const results = await (0, manifest_1.generateManifests)(config, configDir, {
710
+ storyIds,
711
+ outputFolder: opts.outputFolder,
712
+ format: opts.format,
713
+ force: opts.force ?? false,
714
+ dryRun: opts.dryRun ?? false,
715
+ });
716
+ if (outputFormat === 'json') {
717
+ process.stdout.write(JSON.stringify(results, null, 2) + '\n');
718
+ return;
719
+ }
720
+ for (const r of results) {
721
+ const symbol = r.action === 'created' ? chalk_1.default.green('+') : chalk_1.default.dim('=');
722
+ const relPath = path.relative(process.cwd(), r.filePath);
723
+ console.log(`${symbol} [#${r.storyId}] ${relPath} — ${r.title}`);
724
+ if (r.manifest && r.action === 'created') {
725
+ console.log(chalk_1.default.dim(` ${r.manifest.context.acceptanceCriteria.length} AC items · ${r.manifest.context.suggestedTags.join(' ')} · ${r.manifest.context.relatedTestCases.length} linked TCs`));
726
+ }
727
+ }
728
+ console.log('');
729
+ const created = results.filter((r) => r.action === 'created').length;
730
+ const skipped = results.filter((r) => r.action === 'skipped').length;
731
+ console.log([
732
+ created && chalk_1.default.green(`${created} manifest${created !== 1 ? 's' : ''} created`),
733
+ skipped && chalk_1.default.dim(`${skipped} skipped`),
734
+ ].filter(Boolean).join(chalk_1.default.dim(' ')) || chalk_1.default.dim('Nothing to generate.'));
735
+ return;
736
+ }
737
+ // ── Spec file mode ─────────────────────────────────────────────────────
629
738
  console.log(chalk_1.default.bold('ado-sync generate'));
630
739
  console.log(chalk_1.default.dim(`Config: ${configPath}`));
631
740
  if (opts.dryRun)
632
741
  console.log(chalk_1.default.yellow('Dry run — no files will be written'));
633
742
  console.log('');
634
- const storyIds = opts.storyIds
635
- ? opts.storyIds.split(',').map((s) => parseInt(s.trim(), 10)).filter(Boolean)
636
- : undefined;
637
- const outputFormat = program.opts().output;
638
743
  const results = await (0, generate_1.generateSpecs)(config, configDir, {
639
744
  storyIds,
640
745
  query: opts.query,
@@ -665,6 +770,238 @@ program
665
770
  handleError(err);
666
771
  }
667
772
  });
773
+ // ─── story-context ────────────────────────────────────────────────────────────
774
+ program
775
+ .command('story-context')
776
+ .description('Show planner-agent context for an ADO User Story (AC items, suggested tags, linked TCs)')
777
+ .requiredOption('--story-id <id>', 'ADO work item ID')
778
+ .option('--config-override <path=value>', 'Override a config value (repeatable)', collect, [])
779
+ .action(async (opts) => {
780
+ const globalOpts = program.opts();
781
+ try {
782
+ const configPath = (0, config_1.resolveConfigPath)(globalOpts.config);
783
+ const config = (0, config_1.loadConfig)(configPath);
784
+ if (opts.configOverride?.length)
785
+ (0, config_1.applyOverrides)(config, opts.configOverride);
786
+ const { AzureClient } = await Promise.resolve().then(() => __importStar(require('./azure/client')));
787
+ const { getStoryContext } = await Promise.resolve().then(() => __importStar(require('./azure/work-items')));
788
+ const client = await AzureClient.create(config);
789
+ const ctx = await getStoryContext(client, config.project, parseInt(opts.storyId, 10), config.orgUrl);
790
+ if (globalOpts.output === 'json') {
791
+ process.stdout.write(JSON.stringify(ctx, null, 2) + '\n');
792
+ return;
793
+ }
794
+ console.log(chalk_1.default.bold(`Story #${ctx.storyId}: ${ctx.title}`));
795
+ console.log(chalk_1.default.dim(`State: ${ctx.state ?? 'unknown'} | ${ctx.url}`));
796
+ console.log('');
797
+ if (ctx.acItems.length) {
798
+ console.log(chalk_1.default.bold('Acceptance Criteria:'));
799
+ ctx.acItems.forEach((item, i) => console.log(` ${chalk_1.default.dim(`${i + 1}.`)} ${item}`));
800
+ console.log('');
801
+ }
802
+ if (ctx.suggestedTags.length) {
803
+ console.log(`${chalk_1.default.bold('Suggested tags:')} ${ctx.suggestedTags.join(' ')}`);
804
+ }
805
+ if (ctx.suggestedActors.length) {
806
+ console.log(`${chalk_1.default.bold('Actors:')} ${ctx.suggestedActors.join(', ')}`);
807
+ }
808
+ if (ctx.relatedTestCases.length) {
809
+ console.log(`${chalk_1.default.bold('Linked TCs:')} ${ctx.relatedTestCases.map((id) => `#${id}`).join(', ')}`);
810
+ }
811
+ else {
812
+ console.log(chalk_1.default.dim('No linked test cases yet.'));
813
+ }
814
+ }
815
+ catch (err) {
816
+ handleError(err);
817
+ }
818
+ });
819
+ // ─── stale ───────────────────────────────────────────────────────────────────
820
+ program
821
+ .command('stale')
822
+ .description('List Azure DevOps Test Cases that have no corresponding local spec')
823
+ .option('--tags <expression>', 'Only consider local specs matching this tag expression')
824
+ .option('--config-override <path=value>', 'Override a config value (repeatable)', collect, [])
825
+ .action(async (opts) => {
826
+ const globalOpts = program.opts();
827
+ try {
828
+ const configPath = (0, config_1.resolveConfigPath)(globalOpts.config);
829
+ const config = (0, config_1.loadConfig)(configPath);
830
+ if (opts.configOverride?.length)
831
+ (0, config_1.applyOverrides)(config, opts.configOverride);
832
+ const configDir = path.dirname(configPath);
833
+ console.log(chalk_1.default.bold('ado-sync stale'));
834
+ console.log(chalk_1.default.dim(`Config: ${configPath}`));
835
+ if (opts.tags)
836
+ console.log(chalk_1.default.dim(`Tags: ${opts.tags}`));
837
+ console.log('');
838
+ const staleCases = await (0, engine_1.detectStaleTestCases)(config, configDir, { tags: opts.tags });
839
+ const outputFormat = globalOpts.output;
840
+ if (outputFormat === 'json') {
841
+ process.stdout.write(JSON.stringify(staleCases, null, 2) + '\n');
842
+ return;
843
+ }
844
+ if (staleCases.length === 0) {
845
+ console.log(chalk_1.default.green('No stale test cases found.'));
846
+ return;
847
+ }
848
+ for (const tc of staleCases) {
849
+ const tagsStr = tc.tags.length ? chalk_1.default.dim(` [${tc.tags.join(', ')}]`) : '';
850
+ console.log(`${chalk_1.default.yellow('?')} ${chalk_1.default.dim(`[#${tc.id}]`)} ${tc.title}${tagsStr}`);
851
+ }
852
+ console.log('');
853
+ console.log(chalk_1.default.yellow(`${staleCases.length} stale test case${staleCases.length !== 1 ? 's' : ''} — not referenced by any local spec.`));
854
+ console.log(chalk_1.default.dim('Run `ado-sync push` to tag them as ado-sync:removed, or delete them manually.'));
855
+ }
856
+ catch (err) {
857
+ handleError(err);
858
+ }
859
+ });
860
+ // ─── coverage ────────────────────────────────────────────────────────────────
861
+ program
862
+ .command('coverage')
863
+ .description('Show spec link rate and story coverage for local specs vs Azure DevOps')
864
+ .option('--tags <expression>', 'Only consider local specs matching this tag expression')
865
+ .option('--config-override <path=value>', 'Override a config value (repeatable)', collect, [])
866
+ .option('--fail-below <percent>', 'Exit with code 1 when spec link rate is below this threshold (0–100)')
867
+ .action(async (opts) => {
868
+ const globalOpts = program.opts();
869
+ try {
870
+ const configPath = (0, config_1.resolveConfigPath)(globalOpts.config);
871
+ const config = (0, config_1.loadConfig)(configPath);
872
+ if (opts.configOverride?.length)
873
+ (0, config_1.applyOverrides)(config, opts.configOverride);
874
+ const configDir = path.dirname(configPath);
875
+ console.log(chalk_1.default.bold('ado-sync coverage'));
876
+ console.log(chalk_1.default.dim(`Config: ${configPath}`));
877
+ if (opts.tags)
878
+ console.log(chalk_1.default.dim(`Tags: ${opts.tags}`));
879
+ console.log('');
880
+ const report = await (0, engine_1.coverageReport)(config, configDir, { tags: opts.tags });
881
+ const outputFormat = globalOpts.output;
882
+ if (outputFormat === 'json') {
883
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n');
884
+ }
885
+ else {
886
+ const linkColor = report.specLinkRate >= 80 ? chalk_1.default.green : report.specLinkRate >= 50 ? chalk_1.default.yellow : chalk_1.default.red;
887
+ console.log(chalk_1.default.bold('Spec link rate'));
888
+ console.log(` ${linkColor(`${report.specLinkRate}%`)} ${chalk_1.default.dim(`(${report.linkedSpecs} of ${report.totalLocalSpecs} specs linked to Azure Test Cases)`)}`);
889
+ if (report.unlinkedSpecs > 0) {
890
+ console.log(` ${chalk_1.default.dim(`${report.unlinkedSpecs} unlinked — run \`ado-sync push\` to create Azure Test Cases for them`)}`);
891
+ }
892
+ if (report.storiesReferenced.length > 0) {
893
+ console.log('');
894
+ const storyColor = report.storyCoverageRate >= 80 ? chalk_1.default.green : report.storyCoverageRate >= 50 ? chalk_1.default.yellow : chalk_1.default.red;
895
+ console.log(chalk_1.default.bold('Story coverage'));
896
+ console.log(` ${storyColor(`${report.storyCoverageRate}%`)} ${chalk_1.default.dim(`(${report.storiesCovered.length} of ${report.storiesReferenced.length} referenced stories have linked specs)`)}`);
897
+ if (report.storiesUncovered.length > 0) {
898
+ console.log(` ${chalk_1.default.dim('Uncovered stories: ')}${report.storiesUncovered.map((id) => `#${id}`).join(', ')}`);
899
+ }
900
+ }
901
+ else {
902
+ console.log('');
903
+ console.log(chalk_1.default.dim('No @story: tags found — add @story:ID tags to specs to track story coverage.'));
904
+ }
905
+ }
906
+ const threshold = opts.failBelow ? parseInt(opts.failBelow, 10) : undefined;
907
+ if (threshold !== undefined && report.specLinkRate < threshold) {
908
+ console.error(chalk_1.default.red(`\nSpec link rate ${report.specLinkRate}% is below threshold ${threshold}%`));
909
+ process.exit(1);
910
+ }
911
+ }
912
+ catch (err) {
913
+ handleError(err);
914
+ }
915
+ });
916
+ // ─── watch ────────────────────────────────────────────────────────────────────
917
+ program
918
+ .command('watch')
919
+ .description('Watch local spec files for changes and auto-push on save')
920
+ .option('--dry-run', 'Show what would change without making any modifications')
921
+ .option('--tags <expression>', 'Only sync scenarios matching this tag expression')
922
+ .option('--debounce <ms>', 'Debounce delay in milliseconds before running push (default: 800)', '800')
923
+ .option('--config-override <path=value>', 'Override a config value (repeatable)', collect, [])
924
+ .option('--ai-provider <provider>', 'AI provider for test step generation')
925
+ .option('--ai-model <model>', 'AI model path or name')
926
+ .option('--ai-url <url>', 'Base URL for AI endpoint')
927
+ .option('--ai-key <key>', 'API key for AI provider')
928
+ .action(async (opts) => {
929
+ const globalOpts = program.opts();
930
+ try {
931
+ const configPath = (0, config_1.resolveConfigPath)(globalOpts.config);
932
+ const config = (0, config_1.loadConfig)(configPath);
933
+ if (opts.configOverride?.length)
934
+ (0, config_1.applyOverrides)(config, opts.configOverride);
935
+ const configDir = path.dirname(configPath);
936
+ const debounceMs = parseInt(opts.debounce ?? '800', 10);
937
+ console.log(chalk_1.default.bold('ado-sync watch'));
938
+ console.log(chalk_1.default.dim(`Config: ${configPath}`));
939
+ console.log(chalk_1.default.dim(`Project: ${config.project}`));
940
+ console.log(chalk_1.default.dim(`Plan: ${config.testPlan.id}`));
941
+ if (opts.dryRun)
942
+ console.log(chalk_1.default.yellow('Dry run — no changes will be made'));
943
+ if (opts.tags)
944
+ console.log(chalk_1.default.dim(`Tags: ${opts.tags}`));
945
+ console.log('');
946
+ console.log(chalk_1.default.dim(`Watching ${configDir} for changes... (Ctrl+C to stop)`));
947
+ console.log('');
948
+ const aiSummary = buildAiOpts(opts, config, configDir);
949
+ let debounceTimer;
950
+ let running = false;
951
+ const runPush = async () => {
952
+ if (running)
953
+ return;
954
+ running = true;
955
+ const ts = new Date().toLocaleTimeString();
956
+ console.log(chalk_1.default.dim(`[${ts}] Change detected — running push...`));
957
+ try {
958
+ const isTTY = process.stdout.isTTY ?? false;
959
+ const onProgress = createProgressCallback(isTTY);
960
+ const results = await (0, engine_1.push)(config, configDir, {
961
+ dryRun: opts.dryRun,
962
+ tags: opts.tags,
963
+ onProgress,
964
+ aiSummary,
965
+ });
966
+ if (isTTY)
967
+ clearProgressLine();
968
+ printResults(results, config.toolSettings?.outputLevel);
969
+ }
970
+ catch (err) {
971
+ console.error(chalk_1.default.red(`Push failed: ${err instanceof Error ? err.message : String(err)}`));
972
+ }
973
+ finally {
974
+ running = false;
975
+ }
976
+ };
977
+ const onChange = () => {
978
+ if (debounceTimer)
979
+ clearTimeout(debounceTimer);
980
+ debounceTimer = setTimeout(runPush, debounceMs);
981
+ };
982
+ // Watch the config directory recursively for changes to spec files
983
+ const watcher = fs.watch(configDir, { recursive: true }, (eventType, filename) => {
984
+ if (!filename)
985
+ return;
986
+ // Only react to spec file changes, not the cache or config file itself
987
+ const skipPatterns = ['.ado-sync-cache.json', 'ado-sync.json', 'ado-sync.yml', 'node_modules'];
988
+ if (skipPatterns.some((p) => filename.includes(p)))
989
+ return;
990
+ onChange();
991
+ });
992
+ // Keep process alive
993
+ process.on('SIGINT', () => {
994
+ watcher.close();
995
+ console.log('\n' + chalk_1.default.dim('Watch mode stopped.'));
996
+ process.exit(0);
997
+ });
998
+ // Trigger an initial push on start
999
+ await runPush();
1000
+ }
1001
+ catch (err) {
1002
+ handleError(err);
1003
+ }
1004
+ });
668
1005
  // ─── Run ─────────────────────────────────────────────────────────────────────
669
1006
  program.parse(process.argv);
670
1007
  //# sourceMappingURL=cli.js.map