mergestorm 0.3.15 → 0.3.17

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,6 +1,6 @@
1
1
  import { readFile, realpath } from "node:fs/promises";
2
2
  import { join, relative, resolve, sep } from "node:path";
3
- import { apiFetch } from "../api.js";
3
+ import { apiFetch, applyPrReviewQuery, isPositiveInteger, } from "../api.js";
4
4
  import { CommandError, REVIEW_EXIT, isCommandErrorCode, rateLimitedMessage } from "../errors.js";
5
5
  import { changedNamesInSandbox, collectChangedFiles, git, parseGithubOriginRepo, repoRoot, } from "../git.js";
6
6
  export const REVIEW_POLL_INTERVAL_MS = 2_000;
@@ -12,6 +12,8 @@ export const REVIEW_POLL_STRETCH_AFTER_MS = 60_000;
12
12
  export const REVIEW_POLL_STRETCH_SPAN_MS = 60_000;
13
13
  /** Same thread slug contract as POST /api/v1/reviews. */
14
14
  export const REVIEW_THREAD_SLUG_RE = /^[a-zA-Z0-9][a-zA-Z0-9._/-]{0,119}$/;
15
+ /** Same product limit as `api/src/v1/reviews.ts` `MAX_DIFF_BYTES`. */
16
+ export const MAX_REVIEW_DIFF_BYTES = 1_000_000;
15
17
  export const MAX_CONTEXT_TEXT_CHARS = 60_000;
16
18
  export const MAX_CONTEXT_FILES = 10;
17
19
  export const MAX_CONTEXT_FILE_CHARS = 16_000;
@@ -25,9 +27,11 @@ export async function collectReviewInput(base, head, cwd = process.cwd(), sandbo
25
27
  diff = "";
26
28
  }
27
29
  else {
28
- const root = repoRoot(cwd);
29
- const specs = names.map((name) => `:(literal)${relative(cwd, join(root, name))}`);
30
- diff = git(["diff", `${base}...${head}`, "--", ...specs], cwd);
30
+ const canonicalCwd = await realpath(cwd);
31
+ const root = repoRoot(canonicalCwd);
32
+ const specs = names.map((name) => `:(literal)${relative(canonicalCwd, join(root, name))}`);
33
+ // Collection must not execute configured diff or text conversion helpers.
34
+ diff = git(["diff", "--no-ext-diff", "--no-textconv", `${base}...${head}`, "--", ...specs], cwd);
31
35
  }
32
36
  }
33
37
  catch (err) {
@@ -83,26 +87,28 @@ export async function loadReviewContext(opts) {
83
87
  let total = 0;
84
88
  for (const rawPath of opts.contextFiles) {
85
89
  const abs = resolve(cwd, rawPath);
86
- if (opts.sandboxCwd) {
87
- let sandboxReal;
88
- let targetReal;
89
- try {
90
- [sandboxReal, targetReal] = await Promise.all([
91
- realpath(resolve(opts.sandboxCwd)),
92
- realpath(abs),
93
- ]);
94
- }
95
- catch {
96
- throw contextUsage(`--context-file ${rawPath} must be inside the working directory`);
97
- }
98
- const relToSandbox = relative(sandboxReal, targetReal);
99
- if (relToSandbox === ".." || relToSandbox.startsWith(`..${sep}`)) {
100
- throw contextUsage(`--context-file ${rawPath} must be inside the working directory`);
101
- }
90
+ const boundaryLabel = opts.sandboxCwd ? "working directory" : "repo root";
91
+ let targetReal;
92
+ let sandboxReal;
93
+ try {
94
+ sandboxReal = opts.sandboxCwd ? await realpath(resolve(opts.sandboxCwd)) : repoRoot(cwd);
95
+ }
96
+ catch {
97
+ throw contextUsage(`--context-file ${rawPath} must be inside the ${boundaryLabel}`);
98
+ }
99
+ try {
100
+ targetReal = await realpath(abs);
101
+ }
102
+ catch (err) {
103
+ const detail = err instanceof Error ? err.message : String(err);
104
+ throw contextUsage(`could not read --context-file ${rawPath}: ${detail}`);
105
+ }
106
+ if (targetReal !== sandboxReal && !targetReal.startsWith(`${sandboxReal}${sep}`)) {
107
+ throw contextUsage(`--context-file ${rawPath} must be inside the ${boundaryLabel}`);
102
108
  }
103
109
  let content;
104
110
  try {
105
- content = await readFile(abs, "utf8");
111
+ content = await readFile(targetReal, "utf8");
106
112
  }
107
113
  catch (err) {
108
114
  const detail = err instanceof Error ? err.message : String(err);
@@ -148,6 +154,39 @@ export async function submitReview(cfg, input, opts = {}) {
148
154
  ...(retryAfterSeconds !== undefined ? { retryAfterSeconds } : {}),
149
155
  };
150
156
  }
157
+ function reviewSubmitErrorText(body) {
158
+ if (body && typeof body === "object") {
159
+ const rec = body;
160
+ const message = typeof rec.message === "string" ? rec.message.trim() : "";
161
+ const error = typeof rec.error === "string" ? rec.error.trim() : "";
162
+ if (message)
163
+ return message;
164
+ if (error)
165
+ return error;
166
+ }
167
+ if (typeof body === "string") {
168
+ const trimmed = body.trim();
169
+ if (trimmed && !trimmed.startsWith("<"))
170
+ return trimmed.slice(0, 240);
171
+ }
172
+ return "";
173
+ }
174
+ /**
175
+ * Human copy for a failed POST /api/v1/reviews.
176
+ * nginx 413 is HTML, so `submitReview` stores `{}` and used to print `Failed: {}`.
177
+ */
178
+ export function formatReviewSubmitError(status, body) {
179
+ const detail = reviewSubmitErrorText(body);
180
+ if (status === 413) {
181
+ if (detail)
182
+ return `${detail} No review job was created and no credits were charged.`;
183
+ return ("Upload rejected (HTTP 413). nginx stopped the body before a review job was created, " +
184
+ "so no credits were charged. Max diff size is 1 MB / 1_000_000 bytes.");
185
+ }
186
+ if (detail)
187
+ return `Failed (HTTP ${status}): ${detail}`;
188
+ return `Failed (HTTP ${status}). The API did not return a JSON error body.`;
189
+ }
151
190
  /** Exponential backoff with jitter in `[0.5, 1.0]` of the base delay. */
152
191
  export function transientRetryWaitMs(input) {
153
192
  const exp = REVIEW_POLL_BACKOFF_MS * 2 ** Math.max(0, input.failureCount - 1);
@@ -242,8 +281,10 @@ export async function pollPrVortexReview(cfg, owner, repo, prNumber, opts = {})
242
281
  repo,
243
282
  pr_number: String(prNumber),
244
283
  });
245
- if (opts.afterSha)
246
- query.set("after_sha", opts.afterSha);
284
+ // #2027: the pass selector is fixed for the whole wait. after_pass stays
285
+ // what the caller handed in on every poll and every retry; it is never
286
+ // replaced by the pass of an in-progress envelope seen along the way.
287
+ applyPrReviewQuery(query, opts);
247
288
  let transientFailures = 0;
248
289
  let lastEnvelope = null;
249
290
  const sleepBeforeRetry = async (retryAfterSeconds) => {
@@ -310,7 +351,17 @@ export async function pollPrVortexReview(cfg, owner, repo, prNumber, opts = {})
310
351
  if (!poll.body || typeof poll.body !== "object") {
311
352
  throw new CommandError("Poll failed: invalid PR review response", REVIEW_EXIT.failed, "review_failed");
312
353
  }
313
- lastEnvelope = poll.body;
354
+ const envelope = poll.body;
355
+ // The server filters by pass; re-check here so a server that ignores the
356
+ // selector can never satisfy the wait with the wrong pass.
357
+ if ((opts.pass !== undefined || opts.afterPass !== undefined) &&
358
+ (!isPositiveInteger(envelope.pass) || (opts.pass !== undefined && envelope.pass !== opts.pass) ||
359
+ (opts.afterPass !== undefined && envelope.pass <= opts.afterPass))) {
360
+ opts.onTick?.("progress");
361
+ await sleepBeforePoll();
362
+ continue;
363
+ }
364
+ lastEnvelope = envelope;
314
365
  const resting = (lastEnvelope.raw_status ?? lastEnvelope.status) !== "in_progress";
315
366
  const matches = !opts.afterSha || shaMatches(lastEnvelope.head_sha, opts.afterSha);
316
367
  if (resting && matches)
@@ -4,9 +4,10 @@ import { CommandError, DetachedError, REVIEW_EXIT, isCommandErrorCode, rateLimit
4
4
  import { discoverTrunk } from "../git-stack.js";
5
5
  import { ansi } from "../ui/ansi.js";
6
6
  import { formatFindingsLines } from "../ui/findings.js";
7
+ import { humanSummary } from "../ui/human-summary.js";
7
8
  import { present } from "../ui/present.js";
8
9
  import { toReviewJobEnvelope, } from "../ui/job-envelope.js";
9
- import { MAX_IDEMPOTENCY_KEY_CHARS, REVIEW_POLL_DEFAULT_TIMEOUT_MS, REVIEW_POLL_MAX_TRANSIENT_RETRIES, REVIEW_THREAD_SLUG_RE, ReviewPollTimeoutError, collectReviewInput, isTransientReviewPollError, isTransientReviewPollStatus, loadReviewContext, pollReview, submitReview, } from "./review-client.js";
10
+ import { MAX_IDEMPOTENCY_KEY_CHARS, REVIEW_POLL_DEFAULT_TIMEOUT_MS, REVIEW_POLL_MAX_TRANSIENT_RETRIES, REVIEW_THREAD_SLUG_RE, ReviewPollTimeoutError, collectReviewInput, isTransientReviewPollError, isTransientReviewPollStatus, loadReviewContext, pollReview, formatReviewSubmitError, submitReview, } from "./review-client.js";
10
11
  export { REVIEW_POLL_MAX_TRANSIENT_RETRIES, isTransientReviewPollError, isTransientReviewPollStatus, };
11
12
  /** Same public specialist allowlist as the API contract and migration 117. */
12
13
  export const VORTEX_ROUTER_MODES = ["off", "standard", "max", "manual"];
@@ -255,7 +256,6 @@ export async function cmdReview(args, opts = {}) {
255
256
  };
256
257
  try {
257
258
  const cfg = await loadConfig();
258
- const meBefore = interactive ? await getMe(cfg) : null;
259
259
  progress(`Collecting diff ${base}...${head} …`);
260
260
  const input = await collectReviewInput(base, head);
261
261
  if (!input) {
@@ -320,7 +320,7 @@ export async function cmdReview(args, opts = {}) {
320
320
  });
321
321
  }
322
322
  if (submitted.status !== 202 && submitted.status !== 200) {
323
- const message = `Failed: ${JSON.stringify(submitted.row, null, 2)}`;
323
+ const message = formatReviewSubmitError(submitted.status, submitted.row);
324
324
  if (json)
325
325
  printJson(submitted.row, { status: "failed", error: message });
326
326
  throw new CommandError(message, REVIEW_EXIT.failed, "review_failed");
@@ -407,21 +407,24 @@ export async function cmdReview(args, opts = {}) {
407
407
  ];
408
408
  if (ran.length > 0)
409
409
  result.push(ansi.dim(`Specialists: ${ran.join(", ")}`));
410
- if (row.summary)
411
- result.push(row.summary);
410
+ const summary = row.summary ? humanSummary(row.summary) : null;
411
+ if (summary)
412
+ result.push(summary);
412
413
  result.push(...formatFindingsLines(row.findings, {
413
414
  emptyMessage: true,
414
415
  }));
415
- if (interactive && meBefore) {
416
- const meAfter = await getMe(cfg);
417
- if (meAfter) {
418
- const beforeLeft = meBefore.usage.standard.remaining ?? 0;
419
- const afterLeft = meAfter.usage.standard.remaining ?? 0;
420
- const used = Math.max(0, beforeLeft - afterLeft);
421
- if (used > 0) {
422
- result.push(ansi.dim(`${used} credit${used === 1 ? "" : "s"} used · ${afterLeft} remaining`));
423
- }
416
+ if (interactive) {
417
+ const me = await getMe(cfg);
418
+ const used = row.credits?.standard;
419
+ const remaining = me?.usage.standard.remaining;
420
+ const credits = [];
421
+ if (used != null) {
422
+ credits.push(`${used} credit${used === 1 ? "" : "s"} used`);
424
423
  }
424
+ if (remaining != null)
425
+ credits.push(`${remaining} remaining`);
426
+ if (credits.length > 0)
427
+ result.push(ansi.dim(credits.join(" · ")));
425
428
  }
426
429
  await present("Review", result);
427
430
  }
@@ -1,9 +1,9 @@
1
- import { type SettingsPatch, type SettingsResponse, type SettingsWritableKey } from "../api.js";
1
+ import { type SettingsPatch, type SettingsResponse } from "../api.js";
2
2
  /**
3
3
  * CLI flag → Bearer settings key. on|off only. The connected flags are
4
4
  * read-only on the API and deliberately have no flag here.
5
5
  */
6
- export declare const SETTINGS_FLAGS: Record<string, SettingsWritableKey>;
6
+ export declare const SETTINGS_FLAGS: Record<string, "auto_review_enabled" | "auto_patch_enabled" | "vortex_show_thinking_traces" | "repo_overview_enabled" | "review_unit_land_prs_enabled" | "cyclone_review_unit_land_prs_enabled" | "vortex_seam_specialist_enabled" | "auto_land_default">;
7
7
  export type SettingsArgs = {
8
8
  json: boolean;
9
9
  patch: SettingsPatch;
@@ -1,4 +1,5 @@
1
1
  import { getSettings, patchSettings, } from "../api.js";
2
+ import { BEARER_SETTINGS_FLAGS } from "../automation-catalog.js";
2
3
  import { loadConfig } from "../config.js";
3
4
  import { CommandError } from "../errors.js";
4
5
  import { present } from "../ui/present.js";
@@ -7,15 +8,7 @@ import { buildConfigRows, canBrowse, openTabsBrowser } from "./browse.js";
7
8
  * CLI flag → Bearer settings key. on|off only. The connected flags are
8
9
  * read-only on the API and deliberately have no flag here.
9
10
  */
10
- export const SETTINGS_FLAGS = {
11
- "--auto-review": "auto_review_enabled",
12
- "--auto-patch": "auto_patch_enabled",
13
- "--vortex-thinking": "vortex_show_thinking_traces",
14
- "--repo-overview": "repo_overview_enabled",
15
- "--review-unit-land": "review_unit_land_prs_enabled",
16
- "--cyclone-review-unit-land": "cyclone_review_unit_land_prs_enabled",
17
- "--vortex-seam": "vortex_seam_specialist_enabled",
18
- };
11
+ export const SETTINGS_FLAGS = BEARER_SETTINGS_FLAGS;
19
12
  /** Parse `settings` argv. Accepts `--flag on|off` and `--flag=on|off`. */
20
13
  export function parseSettingsArgs(args) {
21
14
  let json = false;
@@ -1,9 +1,10 @@
1
- import { adoptStack, ensureUpperPark, listStacks, type StackDto } from "../api.js";
1
+ import { adoptStack, ensureUpperPark, listStacks, setStackPolicy, type StackDto, type StackPolicyPatch } from "../api.js";
2
2
  import { loadConfig } from "../config.js";
3
3
  import { createPr, findOpenPrNumber } from "../gh.js";
4
4
  import { parseGithubOriginRepo } from "../git.js";
5
- import { branchExists, commitsAheadOf, fetchRemoteBranch, pushBranch, tipCommitMessage, tipCommitSubject } from "../git-stack.js";
5
+ import { branchExists, commitsAheadOf, createBranchFromHead, currentBranch, defaultLayerBranchName, deleteBranch, discoverTrunk, fetchRemoteBranch, pushBranch, tipCommitMessage, tipCommitSubject, worktreeDirty } from "../git-stack.js";
6
6
  import { buildPrBodyFromCommit } from "../pr-body.js";
7
+ import { gitTopLevel } from "../repo-identity.js";
7
8
  import { loadStackMeta, saveStackMeta } from "../stack-meta.js";
8
9
  /** A registered stack layer that would become the parent of a new layer. */
9
10
  export type RegisteredParentHit = {
@@ -30,27 +31,72 @@ export declare function parseAdoptTarget(args: string[]): {
30
31
  };
31
32
  /** Empty + populated bodies for the TTY `stack list` panel. */
32
33
  export declare function buildStackListLines(stacks: StackDto[]): string[];
34
+ /** Short per-stack policy suffixes: Auto land when on, overrides when pinned. */
35
+ export declare function stackPolicyLabels(s: Pick<StackDto, "autoEnqueueWhenReady" | "autoReviewOverride" | "autoPatchOverride">): string[];
36
+ /** Per-open policy flags shared by create, submit, and adopt (`on|off` only). */
37
+ export type StackOpenPolicyFlags = {
38
+ autoLand?: boolean;
39
+ autoReview?: boolean;
40
+ autoPatch?: boolean;
41
+ };
42
+ /** Wire policy for adopt from the parsed `on|off` flags; absent keys stay off the body. */
43
+ export declare function openPolicyPatch(flags: StackOpenPolicyFlags): StackPolicyPatch | undefined;
33
44
  /** Parse `stack create` argv into options. Exported for tests. */
34
45
  export declare function parseStackCreateArgs(args: string[]): {
35
46
  name?: string;
36
47
  onto?: string;
37
48
  trunk?: string;
38
49
  extend: boolean;
50
+ autoLand?: boolean;
51
+ autoReview?: boolean;
52
+ autoPatch?: boolean;
39
53
  asJson: boolean;
40
54
  };
41
55
  /** Parse `stack submit` argv. Exported for tests. */
42
56
  export declare function parseStackSubmitArgs(args: string[]): {
43
57
  extend: boolean;
58
+ autoLand?: boolean;
59
+ autoReview?: boolean;
60
+ autoPatch?: boolean;
61
+ asJson: boolean;
62
+ };
63
+ export declare function parseStackAdoptArgs(args: string[]): {
64
+ owner: string;
65
+ repo: string;
66
+ prNumber: number;
67
+ autoLand?: boolean;
68
+ autoReview?: boolean;
69
+ autoPatch?: boolean;
70
+ asJson: boolean;
71
+ };
72
+ export type StackSetArgs = {
73
+ stackId: string;
74
+ /** At least one of these is present. */
75
+ autoLand?: boolean;
76
+ autoReview?: boolean | null;
77
+ autoPatch?: boolean | null;
44
78
  asJson: boolean;
45
79
  };
80
+ export declare function parseStackSetArgs(args: string[]): StackSetArgs;
46
81
  export declare function parseStackResetArgs(args: string[]): void;
47
82
  /** Injectable seams for unit tests. Production callers omit deps. */
48
83
  export type StackCreateDeps = {
49
84
  cwd?: string;
85
+ gitTopLevel?: typeof gitTopLevel;
86
+ worktreeDirty?: typeof worktreeDirty;
87
+ currentBranch?: typeof currentBranch;
88
+ branchExists?: typeof branchExists;
89
+ createBranchFromHead?: typeof createBranchFromHead;
90
+ deleteBranch?: typeof deleteBranch;
91
+ discoverTrunk?: typeof discoverTrunk;
92
+ defaultLayerBranchName?: typeof defaultLayerBranchName;
93
+ loadStackMeta?: typeof loadStackMeta;
94
+ saveStackMeta?: typeof saveStackMeta;
50
95
  listStacks?: typeof listStacks;
51
96
  loadConfig?: typeof loadConfig;
52
97
  parseGithubOriginRepo?: typeof parseGithubOriginRepo;
53
98
  };
99
+ export declare function cmdStackCreate(args: string[], deps?: StackCreateDeps): Promise<void>;
54
100
  /** Injectable seams for unit tests (STRUCT-04). Production callers omit deps. */
55
101
  export type StackSubmitDeps = {
56
102
  cwd?: string;
@@ -74,4 +120,11 @@ export type StackSubmitDeps = {
74
120
  ensureUpperPark?: typeof ensureUpperPark;
75
121
  };
76
122
  export declare function cmdStackSubmit(args: string[], deps?: StackSubmitDeps): Promise<void>;
123
+ export type StackSetDeps = {
124
+ loadConfig?: typeof loadConfig;
125
+ setStackPolicy?: typeof setStackPolicy;
126
+ };
127
+ /** Human summary lines for a `stack set` write, one per flag. */
128
+ export declare function stackSetSummary(parsed: StackSetArgs): string[];
129
+ export declare function cmdStackSet(args: string[], deps?: StackSetDeps): Promise<void>;
77
130
  export declare function cmdStack(args: string[]): Promise<void>;