sequant 1.15.3 → 1.16.0

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sequant",
3
3
  "description": "Structured workflow system for Claude Code - GitHub issue resolution with spec, exec, test, and QA phases",
4
- "version": "1.15.3",
4
+ "version": "1.16.0",
5
5
  "author": {
6
6
  "name": "sequant-io",
7
7
  "email": "hello@sequant.io"
package/dist/bin/cli.js CHANGED
@@ -44,6 +44,7 @@ import { statsCommand } from "../src/commands/stats.js";
44
44
  import { dashboardCommand } from "../src/commands/dashboard.js";
45
45
  import { stateInitCommand, stateRebuildCommand, stateCleanCommand, } from "../src/commands/state.js";
46
46
  import { syncCommand, areSkillsOutdated } from "../src/commands/sync.js";
47
+ import { mergeCommand } from "../src/commands/merge.js";
47
48
  import { getManifest } from "../src/lib/manifest.js";
48
49
  const program = new Command();
49
50
  // Handle --no-color before parsing
@@ -142,8 +143,21 @@ program
142
143
  .option("--no-retry", "Disable automatic retry with MCP fallback (useful for debugging)")
143
144
  .option("--resume", "Resume from last completed phase (reads phase markers from GitHub)")
144
145
  .option("--no-rebase", "Skip pre-PR rebase onto origin/main (use when you want to handle rebasing manually)")
146
+ .option("--no-pr", "Skip PR creation after successful QA (manual PR workflow)")
145
147
  .option("-f, --force", "Force re-execution of completed issues (bypass pre-flight state guard)")
146
148
  .action(runCommand);
149
+ program
150
+ .command("merge")
151
+ .description("Batch-level integration QA — verify feature branches before merging")
152
+ .argument("[issues...]", "Issue numbers to check (auto-detects from most recent run if omitted)")
153
+ .option("--check", "Run Phase 1 deterministic checks (default)")
154
+ .option("--scan", "Run Phase 1 + Phase 2 residual pattern detection")
155
+ .option("--review", "Run Phase 1 + 2 + 3 AI briefing")
156
+ .option("--all", "Run all phases")
157
+ .option("--post", "Post report to GitHub as PR comments")
158
+ .option("--json", "Output as JSON")
159
+ .option("-v, --verbose", "Enable verbose output")
160
+ .action(mergeCommand);
147
161
  program
148
162
  .command("logs")
149
163
  .description("View and analyze workflow run logs")
@@ -0,0 +1,22 @@
1
+ /**
2
+ * sequant merge - Batch-level integration QA for completed runs
3
+ *
4
+ * Runs deterministic checks on feature branches from a `sequant run` batch
5
+ * to catch integration issues before human review.
6
+ *
7
+ * Phases:
8
+ * - --check (Phase 1): Combined branch test, mirroring, overlap detection
9
+ * - --scan (Phase 1+2): Adds residual pattern detection
10
+ * - --review (Phase 1+2+3): Adds AI briefing (stub)
11
+ * - --all: Runs all phases
12
+ * - --post: Post report to GitHub PRs
13
+ */
14
+ import type { MergeCommandOptions } from "../lib/merge-check/types.js";
15
+ /**
16
+ * Determine exit code from batch verdict
17
+ */
18
+ export declare function getExitCode(batchVerdict: string): number;
19
+ /**
20
+ * Main merge command handler
21
+ */
22
+ export declare function mergeCommand(issues: string[], options: MergeCommandOptions): Promise<void>;
@@ -0,0 +1,109 @@
1
+ /**
2
+ * sequant merge - Batch-level integration QA for completed runs
3
+ *
4
+ * Runs deterministic checks on feature branches from a `sequant run` batch
5
+ * to catch integration issues before human review.
6
+ *
7
+ * Phases:
8
+ * - --check (Phase 1): Combined branch test, mirroring, overlap detection
9
+ * - --scan (Phase 1+2): Adds residual pattern detection
10
+ * - --review (Phase 1+2+3): Adds AI briefing (stub)
11
+ * - --all: Runs all phases
12
+ * - --post: Post report to GitHub PRs
13
+ */
14
+ import { spawnSync } from "child_process";
15
+ import { ui, colors } from "../lib/cli-ui.js";
16
+ import { runMergeChecks, formatReportMarkdown, } from "../lib/merge-check/index.js";
17
+ /**
18
+ * Determine exit code from batch verdict
19
+ */
20
+ export function getExitCode(batchVerdict) {
21
+ switch (batchVerdict) {
22
+ case "READY":
23
+ return 0;
24
+ case "NEEDS_ATTENTION":
25
+ return 1;
26
+ case "BLOCKED":
27
+ return 2;
28
+ default:
29
+ return 1;
30
+ }
31
+ }
32
+ /**
33
+ * Get the git repository root
34
+ */
35
+ function getRepoRoot() {
36
+ const result = spawnSync("git", ["rev-parse", "--show-toplevel"], {
37
+ stdio: "pipe",
38
+ encoding: "utf-8",
39
+ });
40
+ if (result.status !== 0) {
41
+ throw new Error("Not in a git repository");
42
+ }
43
+ return result.stdout.trim();
44
+ }
45
+ /**
46
+ * Main merge command handler
47
+ */
48
+ export async function mergeCommand(issues, options) {
49
+ // Default to --check if no phase flag is specified
50
+ if (!options.check && !options.scan && !options.review && !options.all) {
51
+ options.check = true;
52
+ }
53
+ const repoRoot = getRepoRoot();
54
+ const issueNumbers = issues
55
+ .map((i) => parseInt(i, 10))
56
+ .filter((n) => !isNaN(n));
57
+ // Determine mode label
58
+ let mode = "check";
59
+ if (options.all)
60
+ mode = "all";
61
+ else if (options.review)
62
+ mode = "review";
63
+ else if (options.scan)
64
+ mode = "scan";
65
+ if (!options.json) {
66
+ console.log(ui.headerBox("SEQUANT MERGE"));
67
+ console.log("");
68
+ console.log(colors.muted(issueNumbers.length > 0
69
+ ? `Checking issues: ${issueNumbers.map((i) => `#${i}`).join(", ")} (mode: ${mode})`
70
+ : `Auto-detecting issues from most recent run (mode: ${mode})`));
71
+ console.log("");
72
+ }
73
+ try {
74
+ const report = await runMergeChecks(issueNumbers, options, repoRoot);
75
+ if (options.json) {
76
+ // JSON output: serialize the report (convert Map to object)
77
+ const jsonReport = {
78
+ ...report,
79
+ issueVerdicts: Object.fromEntries(report.issueVerdicts),
80
+ };
81
+ console.log(JSON.stringify(jsonReport, null, 2));
82
+ }
83
+ else {
84
+ // Markdown output
85
+ const markdown = formatReportMarkdown(report);
86
+ console.log(markdown);
87
+ // Phase 3 stub
88
+ if (options.review || options.all) {
89
+ console.log("");
90
+ console.log(colors.muted("Phase 3 (AI briefing) is not yet implemented. Use --check or --scan for deterministic checks."));
91
+ }
92
+ }
93
+ // Set exit code based on verdict
94
+ const exitCode = getExitCode(report.batchVerdict);
95
+ if (exitCode !== 0) {
96
+ process.exitCode = exitCode;
97
+ }
98
+ }
99
+ catch (error) {
100
+ const message = error instanceof Error ? error.message : String(error);
101
+ if (options.json) {
102
+ console.log(JSON.stringify({ error: message }, null, 2));
103
+ }
104
+ else {
105
+ console.error(ui.errorBox("Merge Check Failed", message));
106
+ }
107
+ process.exitCode = 2;
108
+ }
109
+ }
@@ -130,6 +130,36 @@ export interface RebaseResult {
130
130
  * @internal Exported for testing
131
131
  */
132
132
  export declare function rebaseBeforePR(worktreePath: string, issueNumber: number, packageManager: string | undefined, verbose: boolean): RebaseResult;
133
+ /**
134
+ * Result of PR creation
135
+ */
136
+ export interface PRCreationResult {
137
+ /** Whether PR creation was attempted */
138
+ attempted: boolean;
139
+ /** Whether PR was created successfully (or already existed) */
140
+ success: boolean;
141
+ /** PR number */
142
+ prNumber?: number;
143
+ /** PR URL */
144
+ prUrl?: string;
145
+ /** Error message if failed */
146
+ error?: string;
147
+ }
148
+ /**
149
+ * Push branch and create a PR after successful QA.
150
+ *
151
+ * Handles both fresh PR creation and detection of existing PRs.
152
+ * Failures are warnings — they don't fail the run.
153
+ *
154
+ * @param worktreePath Path to the worktree
155
+ * @param issueNumber Issue number
156
+ * @param issueTitle Issue title (for PR title)
157
+ * @param branch Branch name
158
+ * @param verbose Whether to show verbose output
159
+ * @returns PRCreationResult with PR info or error
160
+ * @internal Exported for testing
161
+ */
162
+ export declare function createPR(worktreePath: string, issueNumber: number, issueTitle: string, branch: string, verbose: boolean, labels?: string[]): PRCreationResult;
133
163
  /**
134
164
  * Detect phases based on issue labels (like /solve logic)
135
165
  */
@@ -207,6 +237,12 @@ interface RunOptions {
207
237
  * Use when you want to preserve branch state or handle rebasing manually.
208
238
  */
209
239
  noRebase?: boolean;
240
+ /**
241
+ * Skip PR creation after successful QA.
242
+ * When true, branches are pushed but no PR is created.
243
+ * Useful for manual workflows where PRs are created separately.
244
+ */
245
+ noPr?: boolean;
210
246
  /**
211
247
  * Force re-execution of issues even if they have completed status.
212
248
  * Bypasses the pre-flight state guard that skips ready_for_merge/merged issues.
@@ -772,6 +772,140 @@ export function rebaseBeforePR(worktreePath, issueNumber, packageManager, verbos
772
772
  reinstalled,
773
773
  };
774
774
  }
775
+ /**
776
+ * Push branch and create a PR after successful QA.
777
+ *
778
+ * Handles both fresh PR creation and detection of existing PRs.
779
+ * Failures are warnings — they don't fail the run.
780
+ *
781
+ * @param worktreePath Path to the worktree
782
+ * @param issueNumber Issue number
783
+ * @param issueTitle Issue title (for PR title)
784
+ * @param branch Branch name
785
+ * @param verbose Whether to show verbose output
786
+ * @returns PRCreationResult with PR info or error
787
+ * @internal Exported for testing
788
+ */
789
+ export function createPR(worktreePath, issueNumber, issueTitle, branch, verbose, labels) {
790
+ // Step 1: Check for existing PR on this branch
791
+ const existingPR = spawnSync("gh", ["pr", "view", branch, "--json", "number,url"], { stdio: "pipe", cwd: worktreePath, timeout: 15000 });
792
+ if (existingPR.status === 0 && existingPR.stdout) {
793
+ try {
794
+ const prInfo = JSON.parse(existingPR.stdout.toString());
795
+ if (prInfo.number && prInfo.url) {
796
+ if (verbose) {
797
+ console.log(chalk.gray(` ℹ️ PR #${prInfo.number} already exists for branch ${branch}`));
798
+ }
799
+ return {
800
+ attempted: true,
801
+ success: true,
802
+ prNumber: prInfo.number,
803
+ prUrl: prInfo.url,
804
+ };
805
+ }
806
+ }
807
+ catch {
808
+ // JSON parse failed — no existing PR, continue to create
809
+ }
810
+ }
811
+ // Step 2: Push branch to remote
812
+ if (verbose) {
813
+ console.log(chalk.gray(` 🚀 Pushing branch ${branch} to origin...`));
814
+ }
815
+ const pushResult = spawnSync("git", ["-C", worktreePath, "push", "-u", "origin", branch], { stdio: "pipe", timeout: 60000 });
816
+ if (pushResult.status !== 0) {
817
+ const pushError = pushResult.stderr?.toString().trim() ?? "Unknown error";
818
+ console.log(chalk.yellow(` ⚠️ git push failed: ${pushError}`));
819
+ return {
820
+ attempted: true,
821
+ success: false,
822
+ error: `git push failed: ${pushError}`,
823
+ };
824
+ }
825
+ // Step 3: Create PR
826
+ if (verbose) {
827
+ console.log(chalk.gray(` 📝 Creating PR for #${issueNumber}...`));
828
+ }
829
+ const isBug = labels?.some((l) => /^bug/i.test(l));
830
+ const prefix = isBug ? "fix" : "feat";
831
+ const prTitle = `${prefix}(#${issueNumber}): ${issueTitle}`;
832
+ const prBody = [
833
+ `## Summary`,
834
+ ``,
835
+ `Automated PR for issue #${issueNumber}.`,
836
+ ``,
837
+ `Fixes #${issueNumber}`,
838
+ ``,
839
+ `---`,
840
+ `🤖 Generated by \`sequant run\``,
841
+ ].join("\n");
842
+ const prResult = spawnSync("gh", ["pr", "create", "--title", prTitle, "--body", prBody, "--head", branch], { stdio: "pipe", cwd: worktreePath, timeout: 30000 });
843
+ if (prResult.status !== 0) {
844
+ const prError = prResult.stderr?.toString().trim() ?? "Unknown error";
845
+ // Check if PR already exists (race condition or push-before-PR scenarios)
846
+ if (prError.includes("already exists")) {
847
+ const retryView = spawnSync("gh", ["pr", "view", branch, "--json", "number,url"], { stdio: "pipe", cwd: worktreePath, timeout: 15000 });
848
+ if (retryView.status === 0 && retryView.stdout) {
849
+ try {
850
+ const prInfo = JSON.parse(retryView.stdout.toString());
851
+ return {
852
+ attempted: true,
853
+ success: true,
854
+ prNumber: prInfo.number,
855
+ prUrl: prInfo.url,
856
+ };
857
+ }
858
+ catch {
859
+ // Fall through to error
860
+ }
861
+ }
862
+ }
863
+ console.log(chalk.yellow(` ⚠️ PR creation failed: ${prError}`));
864
+ return {
865
+ attempted: true,
866
+ success: false,
867
+ error: `gh pr create failed: ${prError}`,
868
+ };
869
+ }
870
+ // Step 4: Extract PR URL from output and get PR details
871
+ const prOutput = prResult.stdout?.toString().trim() ?? "";
872
+ const prUrlMatch = prOutput.match(/https:\/\/github\.com\/[^\s]+\/pull\/(\d+)/);
873
+ if (prUrlMatch) {
874
+ const prNumber = parseInt(prUrlMatch[1], 10);
875
+ const prUrl = prUrlMatch[0];
876
+ console.log(chalk.green(` ✅ PR #${prNumber} created: ${prUrl}`));
877
+ return {
878
+ attempted: true,
879
+ success: true,
880
+ prNumber,
881
+ prUrl,
882
+ };
883
+ }
884
+ // Fallback: try gh pr view to get details
885
+ const viewResult = spawnSync("gh", ["pr", "view", branch, "--json", "number,url"], { stdio: "pipe", cwd: worktreePath, timeout: 15000 });
886
+ if (viewResult.status === 0 && viewResult.stdout) {
887
+ try {
888
+ const prInfo = JSON.parse(viewResult.stdout.toString());
889
+ console.log(chalk.green(` ✅ PR #${prInfo.number} created: ${prInfo.url}`));
890
+ return {
891
+ attempted: true,
892
+ success: true,
893
+ prNumber: prInfo.number,
894
+ prUrl: prInfo.url,
895
+ };
896
+ }
897
+ catch {
898
+ // Fall through
899
+ }
900
+ }
901
+ // PR was created but we couldn't parse the URL
902
+ console.log(chalk.yellow(` ⚠️ PR created but could not extract URL from output: ${prOutput}`));
903
+ return {
904
+ attempted: true,
905
+ success: true,
906
+ error: "PR created but URL extraction failed",
907
+ };
908
+ }
775
909
  /**
776
910
  * Natural language prompts for each phase
777
911
  * These prompts will invoke the corresponding skills via natural language
@@ -1446,18 +1580,31 @@ export async function runCommand(issues, options) {
1446
1580
  const envConfig = getEnvConfig();
1447
1581
  // Settings provide defaults, env overrides settings, CLI overrides all
1448
1582
  // Note: phases are auto-detected per-issue unless --phases is explicitly set
1583
+ // Commander.js converts --no-X to { X: false }, not { noX: true }.
1584
+ // Normalize these so RunOptions fields (noLog, noMcp, etc.) work correctly.
1585
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1586
+ const cliOpts = options;
1587
+ const normalizedOptions = {
1588
+ ...options,
1589
+ ...(cliOpts.log === false && { noLog: true }),
1590
+ ...(cliOpts.smartTests === false && { noSmartTests: true }),
1591
+ ...(cliOpts.mcp === false && { noMcp: true }),
1592
+ ...(cliOpts.retry === false && { noRetry: true }),
1593
+ ...(cliOpts.rebase === false && { noRebase: true }),
1594
+ ...(cliOpts.pr === false && { noPr: true }),
1595
+ };
1449
1596
  const mergedOptions = {
1450
1597
  // Settings defaults (phases removed - now auto-detected)
1451
- sequential: options.sequential ?? settings.run.sequential,
1452
- timeout: options.timeout ?? settings.run.timeout,
1453
- logPath: options.logPath ?? settings.run.logPath,
1454
- qualityLoop: options.qualityLoop ?? settings.run.qualityLoop,
1455
- maxIterations: options.maxIterations ?? settings.run.maxIterations,
1456
- noSmartTests: options.noSmartTests ?? !settings.run.smartTests,
1598
+ sequential: normalizedOptions.sequential ?? settings.run.sequential,
1599
+ timeout: normalizedOptions.timeout ?? settings.run.timeout,
1600
+ logPath: normalizedOptions.logPath ?? settings.run.logPath,
1601
+ qualityLoop: normalizedOptions.qualityLoop ?? settings.run.qualityLoop,
1602
+ maxIterations: normalizedOptions.maxIterations ?? settings.run.maxIterations,
1603
+ noSmartTests: normalizedOptions.noSmartTests ?? !settings.run.smartTests,
1457
1604
  // Env overrides
1458
1605
  ...envConfig,
1459
1606
  // CLI explicit options override all
1460
- ...options,
1607
+ ...normalizedOptions,
1461
1608
  };
1462
1609
  // Determine if we should auto-detect phases from labels
1463
1610
  const autoDetectPhases = !options.phases && settings.run.autoDetectPhases;
@@ -1766,6 +1913,10 @@ export async function runCommand(issues, options) {
1766
1913
  // In chain mode, only the last issue should trigger pre-PR rebase
1767
1914
  mergedOptions.chain ? i === issueNumbers.length - 1 : undefined);
1768
1915
  results.push(result);
1916
+ // Record PR info in log before completing issue
1917
+ if (logWriter && result.prNumber && result.prUrl) {
1918
+ logWriter.setPRInfo(result.prNumber, result.prUrl);
1919
+ }
1769
1920
  // Complete issue logging
1770
1921
  if (logWriter) {
1771
1922
  logWriter.completeIssue();
@@ -1822,6 +1973,10 @@ export async function runCommand(issues, options) {
1822
1973
  const result = await runIssueWithLogging(issueNumber, config, logWriter, stateManager, issueInfo.title, issueInfo.labels, mergedOptions, worktreeInfo?.path, worktreeInfo?.branch, shutdown, false, // Parallel mode doesn't support chain
1823
1974
  manifest.packageManager);
1824
1975
  results.push(result);
1976
+ // Record PR info in log before completing issue
1977
+ if (logWriter && result.prNumber && result.prUrl) {
1978
+ logWriter.setPRInfo(result.prNumber, result.prUrl);
1979
+ }
1825
1980
  // Complete issue logging
1826
1981
  if (logWriter) {
1827
1982
  logWriter.completeIssue();
@@ -1940,7 +2095,10 @@ export async function runCommand(issues, options) {
1940
2095
  .map((p) => p.success ? colors.success(p.phase) : colors.error(p.phase))
1941
2096
  .join(" → ");
1942
2097
  const loopInfo = result.loopTriggered ? colors.warning(" [loop]") : "";
1943
- console.log(` ${status} #${result.issueNumber}: ${phases}${loopInfo}${duration}`);
2098
+ const prInfo = result.prUrl
2099
+ ? colors.muted(` → PR #${result.prNumber}`)
2100
+ : "";
2101
+ console.log(` ${status} #${result.issueNumber}: ${phases}${loopInfo}${prInfo}${duration}`);
1944
2102
  }
1945
2103
  console.log("");
1946
2104
  if (logPath) {
@@ -1987,6 +2145,10 @@ async function executeBatch(issueNumbers, config, logWriter, stateManager, optio
1987
2145
  const result = await runIssueWithLogging(issueNumber, config, logWriter, stateManager, issueInfo.title, issueInfo.labels, options, worktreeInfo?.path, worktreeInfo?.branch, shutdownManager, false, // Batch mode doesn't support chain
1988
2146
  packageManager);
1989
2147
  results.push(result);
2148
+ // Record PR info in log before completing issue
2149
+ if (logWriter && result.prNumber && result.prUrl) {
2150
+ logWriter.setPRInfo(result.prNumber, result.prUrl);
2151
+ }
1990
2152
  // Complete issue logging
1991
2153
  if (logWriter) {
1992
2154
  logWriter.completeIssue();
@@ -2343,11 +2505,36 @@ async function runIssueWithLogging(issueNumber, config, logWriter, stateManager,
2343
2505
  if (shouldRebase) {
2344
2506
  rebaseBeforePR(worktreePath, issueNumber, packageManager, config.verbose);
2345
2507
  }
2508
+ // Create PR after successful QA + rebase (unless --no-pr)
2509
+ let prNumber;
2510
+ let prUrl;
2511
+ const shouldCreatePR = success && worktreePath && branch && !options.noPr;
2512
+ if (shouldCreatePR) {
2513
+ const prResult = createPR(worktreePath, issueNumber, issueTitle, branch, config.verbose, labels);
2514
+ if (prResult.success && prResult.prNumber && prResult.prUrl) {
2515
+ prNumber = prResult.prNumber;
2516
+ prUrl = prResult.prUrl;
2517
+ // Update workflow state with PR info
2518
+ if (stateManager) {
2519
+ try {
2520
+ await stateManager.updatePRInfo(issueNumber, {
2521
+ number: prResult.prNumber,
2522
+ url: prResult.prUrl,
2523
+ });
2524
+ }
2525
+ catch {
2526
+ // State tracking errors shouldn't stop execution
2527
+ }
2528
+ }
2529
+ }
2530
+ }
2346
2531
  return {
2347
2532
  issueNumber,
2348
2533
  success,
2349
2534
  phaseResults,
2350
2535
  durationSeconds,
2351
2536
  loopTriggered,
2537
+ prNumber,
2538
+ prUrl,
2352
2539
  };
2353
2540
  }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Combined branch testing (AC-1)
3
+ *
4
+ * Creates a temporary branch merging all feature branches from a run batch,
5
+ * runs npm test && npm run build on the combined state, and reports results.
6
+ */
7
+ import type { BranchInfo, CheckResult, BranchCheckResult, CheckFinding } from "./types.js";
8
+ /**
9
+ * Create temp branch, merge all feature branches, run tests and build.
10
+ *
11
+ * @param branches - Feature branches to merge
12
+ * @param repoRoot - Path to the git repository root
13
+ * @returns CheckResult with combined test findings
14
+ */
15
+ export declare function runCombinedBranchTest(branches: BranchInfo[], repoRoot: string): CheckResult;
16
+ export declare function buildResult(branchResults: BranchCheckResult[], batchFindings: CheckFinding[], startTime: number): CheckResult;
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Combined branch testing (AC-1)
3
+ *
4
+ * Creates a temporary branch merging all feature branches from a run batch,
5
+ * runs npm test && npm run build on the combined state, and reports results.
6
+ */
7
+ import { spawnSync } from "child_process";
8
+ import { getBranchRef } from "./types.js";
9
+ /**
10
+ * Run a git command and return the result
11
+ */
12
+ function git(args, cwd) {
13
+ const result = spawnSync("git", args, {
14
+ cwd,
15
+ stdio: "pipe",
16
+ encoding: "utf-8",
17
+ });
18
+ return {
19
+ ok: result.status === 0,
20
+ stdout: result.stdout?.trim() ?? "",
21
+ stderr: result.stderr?.trim() ?? "",
22
+ };
23
+ }
24
+ /**
25
+ * Run npm command and return result
26
+ */
27
+ function npm(args, cwd) {
28
+ const result = spawnSync("npm", args, {
29
+ cwd,
30
+ stdio: "pipe",
31
+ encoding: "utf-8",
32
+ timeout: 120_000, // 2 min timeout for test/build
33
+ });
34
+ return {
35
+ ok: result.status === 0,
36
+ stdout: result.stdout?.trim() ?? "",
37
+ stderr: result.stderr?.trim() ?? "",
38
+ };
39
+ }
40
+ /**
41
+ * Create temp branch, merge all feature branches, run tests and build.
42
+ *
43
+ * @param branches - Feature branches to merge
44
+ * @param repoRoot - Path to the git repository root
45
+ * @returns CheckResult with combined test findings
46
+ */
47
+ export function runCombinedBranchTest(branches, repoRoot) {
48
+ const startTime = Date.now();
49
+ const tempBranch = `merge-check/temp-${Date.now()}`;
50
+ const branchResults = [];
51
+ const batchFindings = [];
52
+ const mergeAttempts = [];
53
+ // Save current branch to restore in finally block
54
+ const originalBranch = git(["rev-parse", "--abbrev-ref", "HEAD"], repoRoot);
55
+ try {
56
+ // Fetch latest from remote
57
+ git(["fetch", "origin"], repoRoot);
58
+ // Create temp branch from main
59
+ const createResult = git(["checkout", "-b", tempBranch, "origin/main"], repoRoot);
60
+ if (!createResult.ok) {
61
+ batchFindings.push({
62
+ check: "combined-branch-test",
63
+ severity: "error",
64
+ message: `Failed to create temp branch: ${createResult.stderr}`,
65
+ });
66
+ return buildResult(branchResults, batchFindings, startTime);
67
+ }
68
+ // Merge each feature branch
69
+ for (const branch of branches) {
70
+ const mergeResult = git(["merge", "--no-ff", "--no-edit", getBranchRef(branch)], repoRoot);
71
+ if (mergeResult.ok) {
72
+ mergeAttempts.push({
73
+ issueNumber: branch.issueNumber,
74
+ branch: branch.branch,
75
+ success: true,
76
+ });
77
+ branchResults.push({
78
+ issueNumber: branch.issueNumber,
79
+ verdict: "PASS",
80
+ findings: [
81
+ {
82
+ check: "combined-branch-test",
83
+ severity: "info",
84
+ message: `Branch merged cleanly into combined state`,
85
+ issueNumber: branch.issueNumber,
86
+ },
87
+ ],
88
+ });
89
+ }
90
+ else {
91
+ // Get conflicting files
92
+ const conflictResult = git(["diff", "--name-only", "--diff-filter=U"], repoRoot);
93
+ const conflictFiles = conflictResult.stdout
94
+ ? conflictResult.stdout.split("\n")
95
+ : [];
96
+ mergeAttempts.push({
97
+ issueNumber: branch.issueNumber,
98
+ branch: branch.branch,
99
+ success: false,
100
+ conflictFiles,
101
+ error: mergeResult.stderr,
102
+ });
103
+ branchResults.push({
104
+ issueNumber: branch.issueNumber,
105
+ verdict: "FAIL",
106
+ findings: [
107
+ {
108
+ check: "combined-branch-test",
109
+ severity: "error",
110
+ message: `Merge conflict with ${conflictFiles.length} file(s): ${conflictFiles.join(", ")}`,
111
+ issueNumber: branch.issueNumber,
112
+ },
113
+ ],
114
+ });
115
+ // Abort the failed merge and continue
116
+ git(["merge", "--abort"], repoRoot);
117
+ }
118
+ }
119
+ // If any merges failed, skip tests but report what we have
120
+ const failedMerges = mergeAttempts.filter((m) => !m.success);
121
+ if (failedMerges.length > 0) {
122
+ batchFindings.push({
123
+ check: "combined-branch-test",
124
+ severity: "error",
125
+ message: `${failedMerges.length}/${branches.length} branches had merge conflicts — skipping test/build`,
126
+ });
127
+ return buildResult(branchResults, batchFindings, startTime);
128
+ }
129
+ // Run npm test
130
+ const testResult = npm(["test"], repoRoot);
131
+ if (testResult.ok) {
132
+ batchFindings.push({
133
+ check: "combined-branch-test",
134
+ severity: "info",
135
+ message: "npm test passed on combined state",
136
+ });
137
+ }
138
+ else {
139
+ batchFindings.push({
140
+ check: "combined-branch-test",
141
+ severity: "error",
142
+ message: `npm test failed on combined state: ${testResult.stderr.slice(0, 500)}`,
143
+ });
144
+ }
145
+ // Run npm run build
146
+ const buildResult2 = npm(["run", "build"], repoRoot);
147
+ if (buildResult2.ok) {
148
+ batchFindings.push({
149
+ check: "combined-branch-test",
150
+ severity: "info",
151
+ message: "npm run build passed on combined state",
152
+ });
153
+ }
154
+ else {
155
+ batchFindings.push({
156
+ check: "combined-branch-test",
157
+ severity: "error",
158
+ message: `npm run build failed on combined state: ${buildResult2.stderr.slice(0, 500)}`,
159
+ });
160
+ }
161
+ return buildResult(branchResults, batchFindings, startTime);
162
+ }
163
+ finally {
164
+ // Clean up: restore original branch and delete temp branch
165
+ const restoreBranch = originalBranch.ok ? originalBranch.stdout : "main";
166
+ git(["checkout", restoreBranch], repoRoot);
167
+ git(["branch", "-D", tempBranch], repoRoot);
168
+ }
169
+ }
170
+ export function buildResult(branchResults, batchFindings, startTime) {
171
+ const hasErrors = batchFindings.some((f) => f.severity === "error");
172
+ return {
173
+ name: "combined-branch-test",
174
+ passed: !hasErrors,
175
+ branchResults,
176
+ batchFindings,
177
+ durationMs: Date.now() - startTime,
178
+ };
179
+ }