mergestorm 0.3.16 → 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.
- package/README.md +2 -2
- package/dist/api.d.ts +21 -1
- package/dist/api.js +17 -1
- package/dist/client/index.d.ts +2 -1
- package/dist/client/index.js +2 -1
- package/dist/commands/browse.js +5 -2
- package/dist/commands/credits.js +1 -0
- package/dist/commands/pr.d.ts +4 -0
- package/dist/commands/pr.js +32 -5
- package/dist/commands/registry.js +2 -1
- package/dist/commands/review-client.d.ts +10 -3
- package/dist/commands/review-client.js +75 -24
- package/dist/commands/review.js +17 -14
- package/dist/commands/stack.js +11 -5
- package/dist/commands/status.js +4 -2
- package/dist/commands/whoami.js +9 -9
- package/dist/git.js +1 -1
- package/dist/stack-dto.d.ts +45 -2
- package/dist/stack-dto.js +0 -6
- package/dist/ui/banner.js +3 -0
- package/dist/ui/human-summary.d.ts +2 -0
- package/dist/ui/human-summary.js +153 -0
- package/dist/ui/usage.d.ts +1 -2
- package/dist/ui/usage.js +3 -4
- package/package.json +1 -1
- package/skill/SKILL.md +3 -3
- package/skill/mergestorm-pr-loop/SKILL.md +28 -21
package/README.md
CHANGED
|
@@ -45,12 +45,12 @@ On a TTY, bare `mergestorm` (or `mergestorm shell`) opens a branded REPL: a comp
|
|
|
45
45
|
|
|
46
46
|
```
|
|
47
47
|
╭──────────────────────────────────────────────────────────────────────────────╮
|
|
48
|
-
│ ▟██▛ mergestorm v0.3.
|
|
48
|
+
│ ▟██▛ mergestorm v0.3.17 │
|
|
49
49
|
│ ▜▙ ● msk_live_… · maelstrom │
|
|
50
50
|
│ [████████░░░░░░░░░░░░░░░░░░░░░░] 25% used · Resets Sep 5, 10:53am (UTC)│
|
|
51
51
|
│ │
|
|
52
52
|
│ review a diff · usage for tabs · /help for all commands │
|
|
53
|
-
│ New in v0.3.
|
|
53
|
+
│ New in v0.3.17: nginx 413 names the upload limit instead of Failed: {} │
|
|
54
54
|
╰──────────────────────────────────────────────────────────────────────────────╯
|
|
55
55
|
mergestorm
|
|
56
56
|
╭──────────────────────────────────────────────────────────────────────────────╮
|
package/dist/api.d.ts
CHANGED
|
@@ -46,6 +46,10 @@ export type MeResponse = {
|
|
|
46
46
|
limit: number | null;
|
|
47
47
|
remaining: number | null;
|
|
48
48
|
};
|
|
49
|
+
/** Non-expiring credits spent after the monthly standard pool. */
|
|
50
|
+
bonus?: {
|
|
51
|
+
remaining: number;
|
|
52
|
+
};
|
|
49
53
|
};
|
|
50
54
|
};
|
|
51
55
|
export type JobListItem = {
|
|
@@ -122,6 +126,8 @@ export type PrVortexReview = {
|
|
|
122
126
|
verdict: string | null;
|
|
123
127
|
summary?: string | null;
|
|
124
128
|
head_sha: string | null;
|
|
129
|
+
/** Attempt number on this head (#2027). Absent only from servers older than the pass column. */
|
|
130
|
+
pass?: number;
|
|
125
131
|
review_count: number;
|
|
126
132
|
skip_reason: string | null;
|
|
127
133
|
reviewed_at: string | null;
|
|
@@ -129,6 +135,19 @@ export type PrVortexReview = {
|
|
|
129
135
|
findings: PrReviewFindings | null;
|
|
130
136
|
patch_policy: unknown | null;
|
|
131
137
|
};
|
|
138
|
+
/**
|
|
139
|
+
* Pass selection on the DB-only PR review read (#2027). `pass` names one row;
|
|
140
|
+
* `afterPass` asks for a later pass (`pass > afterPass`) on the SHA filter.
|
|
141
|
+
*/
|
|
142
|
+
export type PrReviewPassSelector = {
|
|
143
|
+
pass?: number;
|
|
144
|
+
afterPass?: number;
|
|
145
|
+
};
|
|
146
|
+
export declare function isPositiveInteger(value: unknown): value is number;
|
|
147
|
+
/** Append pass selection and SHA filter to a PR review query. */
|
|
148
|
+
export declare function applyPrReviewQuery(query: URLSearchParams, opts: PrReviewPassSelector & {
|
|
149
|
+
afterSha?: string;
|
|
150
|
+
}): void;
|
|
132
151
|
/**
|
|
133
152
|
* Bearer-writable settings keys, mirroring the `/api/v1/settings` allowlist.
|
|
134
153
|
* The connected flags are read-only there and deliberately absent here.
|
|
@@ -172,7 +191,8 @@ export declare function getReview(jobId: string, cfg?: Config, opts?: {
|
|
|
172
191
|
export declare function getPrVortexReview(owner: string, repo: string, prNumber: number, cfg?: Config, opts?: {
|
|
173
192
|
signal?: AbortSignal;
|
|
174
193
|
timeoutMs?: number;
|
|
175
|
-
|
|
194
|
+
afterSha?: string;
|
|
195
|
+
} & PrReviewPassSelector): Promise<PrVortexReview>;
|
|
176
196
|
/** List stacks (Bearer GET /api/v1/stacks). */
|
|
177
197
|
export declare function listStacks(cfg?: Config, opts?: {
|
|
178
198
|
timeoutMs?: number;
|
package/dist/api.js
CHANGED
|
@@ -155,6 +155,18 @@ export async function devicePost(base, route, json, opts = {}) {
|
|
|
155
155
|
}
|
|
156
156
|
return { status: res.status, body: parsed };
|
|
157
157
|
}
|
|
158
|
+
export function isPositiveInteger(value) {
|
|
159
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 1;
|
|
160
|
+
}
|
|
161
|
+
/** Append pass selection and SHA filter to a PR review query. */
|
|
162
|
+
export function applyPrReviewQuery(query, opts) {
|
|
163
|
+
if (opts.afterSha)
|
|
164
|
+
query.set("after_sha", opts.afterSha);
|
|
165
|
+
if (opts.pass !== undefined)
|
|
166
|
+
query.set("pass", String(opts.pass));
|
|
167
|
+
if (opts.afterPass !== undefined)
|
|
168
|
+
query.set("after_pass", String(opts.afterPass));
|
|
169
|
+
}
|
|
158
170
|
/**
|
|
159
171
|
* Bearer-writable settings keys, mirroring the `/api/v1/settings` allowlist.
|
|
160
172
|
* The connected flags are read-only there and deliberately absent here.
|
|
@@ -283,6 +295,7 @@ export async function getPrVortexReview(owner, repo, prNumber, cfg, opts) {
|
|
|
283
295
|
repo: cleanRepo,
|
|
284
296
|
pr_number: String(prNumber),
|
|
285
297
|
});
|
|
298
|
+
applyPrReviewQuery(query, opts ?? {});
|
|
286
299
|
const { status, body, retryAfterSeconds } = await apiFetch(resolved, `/api/v1/stacks/pr-review?${query.toString()}`, { signal: opts?.signal, timeoutMs: opts?.timeoutMs });
|
|
287
300
|
if (status === 404) {
|
|
288
301
|
throw new CommandError(`Vortex review not found: ${cleanOwner}/${cleanRepo}#${prNumber}`, REVIEW_EXIT.failed, "not_found");
|
|
@@ -353,7 +366,7 @@ export function stackPolicyBody(policy) {
|
|
|
353
366
|
/** Adopt an open PR chain (Bearer POST /api/v1/stacks/adopt). */
|
|
354
367
|
export async function adoptStack(owner, repo, prNumber, cfg, policy) {
|
|
355
368
|
const resolved = cfg ?? (await loadConfig());
|
|
356
|
-
const { status, body } = await apiFetch(resolved, "/api/v1/stacks/adopt", {
|
|
369
|
+
const { status, body, retryAfterSeconds } = await apiFetch(resolved, "/api/v1/stacks/adopt", {
|
|
357
370
|
method: "POST",
|
|
358
371
|
json: {
|
|
359
372
|
owner,
|
|
@@ -365,6 +378,9 @@ export async function adoptStack(owner, repo, prNumber, cfg, policy) {
|
|
|
365
378
|
if (status === 404) {
|
|
366
379
|
throw new CommandError("Stacks API is not available on this server yet. Deploy the API update or use the dashboard.");
|
|
367
380
|
}
|
|
381
|
+
if (status === 429) {
|
|
382
|
+
throw new CommandError(rateLimitedMessage(retryAfterSeconds), REVIEW_EXIT.rate_limited, "rate_limited", { retryAfterSeconds });
|
|
383
|
+
}
|
|
368
384
|
if (status !== 200) {
|
|
369
385
|
throw new CommandError(`Failed to import stack (HTTP ${status}): ${JSON.stringify(body)}`);
|
|
370
386
|
}
|
package/dist/client/index.d.ts
CHANGED
|
@@ -2,5 +2,6 @@ export { DEFAULT_API, apiBase, clearConfig, configPath, keyDisplay, loadConfig,
|
|
|
2
2
|
export { BEARER_SETTINGS, BEARER_SETTINGS_FLAGS, BEARER_SETTINGS_KEYS, BEARER_SETTINGS_LABELS, type BearerSettingsKey, } from "../automation-catalog.js";
|
|
3
3
|
export { API_TIMEOUT_PREFIX, DEFAULT_API_TIMEOUT_MS, SETTINGS_WRITABLE_KEYS, adoptStack, apiFetch, cancelMergeQueueEntry, devicePost, enqueueStack, ensureUpperPark, getEnrichedStack, getMe, getPrVortexReview, getReview, getSettings, landNextStack, listJobs, listMergeQueueEntries, listStacks, listThreads, parseRetryAfterSeconds, patchSettings, restackStack, setStackAutoLand, setStackPolicy, stackPolicyBody, type ApiFetchInit, type ApiFetchResult, type JobListItem, type MeResponse, type MergeQueueBounceDetail, type MergeQueueBounceKind, type MergeQueueEnqueuedBy, type MergeQueueEnqueuedVia, type MergeQueueEntryDto, type MergeQueueEntryState, type PrReviewFinding, type PrReviewFindings, type PrVortexReview, type SettingsPatch, type SettingsResponse, type SettingsWritableKey, type StackDto, type StackPolicyPatch, type ThreadDetail, type ThreadListItem, } from "../api.js";
|
|
4
4
|
export { CommandError, DetachedError, RATE_LIMITED_HINT, REVIEW_EXIT, isCommandErrorCode, rateLimitedMessage, type CommandErrorCode, type CommandErrorExtras, } from "../errors.js";
|
|
5
|
-
export { REVIEW_POLL_BACKOFF_MS, REVIEW_POLL_DEFAULT_TIMEOUT_MS, REVIEW_POLL_INTERVAL_MS, REVIEW_POLL_MAX_INTERVAL_MS, REVIEW_POLL_MAX_TRANSIENT_RETRIES, REVIEW_POLL_STRETCH_AFTER_MS, REVIEW_POLL_STRETCH_SPAN_MS, REVIEW_THREAD_SLUG_RE, PrReviewPollTimeoutError, ReviewPollTimeoutError, collectReviewInput, isTransientReviewPollError, isTransientReviewPollStatus, loadReviewContext, pollPrVortexReview, pollReview, stretchedPollIntervalMs, submitReview, transientRetryWaitMs, type PollReviewOptions, type PollPrVortexReviewOptions, type ReviewInput, type SubmitReviewOptions, } from "../commands/review-client.js";
|
|
5
|
+
export { REVIEW_POLL_BACKOFF_MS, REVIEW_POLL_DEFAULT_TIMEOUT_MS, REVIEW_POLL_INTERVAL_MS, REVIEW_POLL_MAX_INTERVAL_MS, REVIEW_POLL_MAX_TRANSIENT_RETRIES, REVIEW_POLL_STRETCH_AFTER_MS, REVIEW_POLL_STRETCH_SPAN_MS, REVIEW_THREAD_SLUG_RE, PrReviewPollTimeoutError, ReviewPollTimeoutError, collectReviewInput, formatReviewSubmitError, isTransientReviewPollError, isTransientReviewPollStatus, loadReviewContext, pollPrVortexReview, pollReview, stretchedPollIntervalMs, submitReview, transientRetryWaitMs, type PollReviewOptions, type PollPrVortexReviewOptions, type ReviewInput, type SubmitReviewOptions, } from "../commands/review-client.js";
|
|
6
6
|
export { REVIEW_JOB_ENVELOPE_SCHEMA, toReviewJobEnvelope, type ReviewEnvelopeFallbacks, type ReviewJobEnvelope, type ReviewJobRow, } from "../ui/job-envelope.js";
|
|
7
|
+
export { discoverTrunk } from "../git-stack.js";
|
package/dist/client/index.js
CHANGED
|
@@ -2,5 +2,6 @@ export { DEFAULT_API, apiBase, clearConfig, configPath, keyDisplay, loadConfig,
|
|
|
2
2
|
export { BEARER_SETTINGS, BEARER_SETTINGS_FLAGS, BEARER_SETTINGS_KEYS, BEARER_SETTINGS_LABELS, } from "../automation-catalog.js";
|
|
3
3
|
export { API_TIMEOUT_PREFIX, DEFAULT_API_TIMEOUT_MS, SETTINGS_WRITABLE_KEYS, adoptStack, apiFetch, cancelMergeQueueEntry, devicePost, enqueueStack, ensureUpperPark, getEnrichedStack, getMe, getPrVortexReview, getReview, getSettings, landNextStack, listJobs, listMergeQueueEntries, listStacks, listThreads, parseRetryAfterSeconds, patchSettings, restackStack, setStackAutoLand, setStackPolicy, stackPolicyBody, } from "../api.js";
|
|
4
4
|
export { CommandError, DetachedError, RATE_LIMITED_HINT, REVIEW_EXIT, isCommandErrorCode, rateLimitedMessage, } from "../errors.js";
|
|
5
|
-
export { REVIEW_POLL_BACKOFF_MS, REVIEW_POLL_DEFAULT_TIMEOUT_MS, REVIEW_POLL_INTERVAL_MS, REVIEW_POLL_MAX_INTERVAL_MS, REVIEW_POLL_MAX_TRANSIENT_RETRIES, REVIEW_POLL_STRETCH_AFTER_MS, REVIEW_POLL_STRETCH_SPAN_MS, REVIEW_THREAD_SLUG_RE, PrReviewPollTimeoutError, ReviewPollTimeoutError, collectReviewInput, isTransientReviewPollError, isTransientReviewPollStatus, loadReviewContext, pollPrVortexReview, pollReview, stretchedPollIntervalMs, submitReview, transientRetryWaitMs, } from "../commands/review-client.js";
|
|
5
|
+
export { REVIEW_POLL_BACKOFF_MS, REVIEW_POLL_DEFAULT_TIMEOUT_MS, REVIEW_POLL_INTERVAL_MS, REVIEW_POLL_MAX_INTERVAL_MS, REVIEW_POLL_MAX_TRANSIENT_RETRIES, REVIEW_POLL_STRETCH_AFTER_MS, REVIEW_POLL_STRETCH_SPAN_MS, REVIEW_THREAD_SLUG_RE, PrReviewPollTimeoutError, ReviewPollTimeoutError, collectReviewInput, formatReviewSubmitError, isTransientReviewPollError, isTransientReviewPollStatus, loadReviewContext, pollPrVortexReview, pollReview, stretchedPollIntervalMs, submitReview, transientRetryWaitMs, } from "../commands/review-client.js";
|
|
6
6
|
export { REVIEW_JOB_ENVELOPE_SCHEMA, toReviewJobEnvelope, } from "../ui/job-envelope.js";
|
|
7
|
+
export { discoverTrunk } from "../git-stack.js";
|
package/dist/commands/browse.js
CHANGED
|
@@ -3,6 +3,7 @@ import { BEARER_SETTINGS_LABELS } from "../automation-catalog.js";
|
|
|
3
3
|
import { apiBase, keyDisplay, loadConfig, resolveApiKey } from "../config.js";
|
|
4
4
|
import { CommandError } from "../errors.js";
|
|
5
5
|
import { ansi } from "../ui/ansi.js";
|
|
6
|
+
import { humanSummary } from "../ui/human-summary.js";
|
|
6
7
|
import { toReviewJobEnvelope, } from "../ui/job-envelope.js";
|
|
7
8
|
import { runTabsBrowser, } from "../ui/tabs.js";
|
|
8
9
|
import { formatDaysLeft, formatResetLabel } from "../ui/usage.js";
|
|
@@ -90,9 +91,10 @@ export function formatJobDetailLines(row) {
|
|
|
90
91
|
lines.push(` Credits ${e.credits.standard} standard`);
|
|
91
92
|
if (e.error)
|
|
92
93
|
lines.push(ansi.red(` Error ${e.error}`));
|
|
93
|
-
|
|
94
|
+
const summary = e.summary ? humanSummary(e.summary) : null;
|
|
95
|
+
if (summary) {
|
|
94
96
|
lines.push("");
|
|
95
|
-
for (const l of
|
|
97
|
+
for (const l of summary.split("\n"))
|
|
96
98
|
lines.push(` ${l}`);
|
|
97
99
|
}
|
|
98
100
|
const inline = e.findings?.inline ?? [];
|
|
@@ -218,6 +220,7 @@ export async function openTabsBrowser(initial) {
|
|
|
218
220
|
plan: me.plan_label_key ?? me.plan_key,
|
|
219
221
|
used: me.usage.standard.used,
|
|
220
222
|
limit: me.usage.standard.limit,
|
|
223
|
+
bonusRemaining: me.usage.bonus?.remaining,
|
|
221
224
|
resetsAt: me.resets_at,
|
|
222
225
|
jobs: jobs.slice(0, 5).map(toPanelJob),
|
|
223
226
|
jobsError: jobsError ?? undefined,
|
package/dist/commands/credits.js
CHANGED
package/dist/commands/pr.d.ts
CHANGED
|
@@ -6,6 +6,10 @@ export type ParsedPrArgs = {
|
|
|
6
6
|
format: "json" | "pretty";
|
|
7
7
|
wait: boolean;
|
|
8
8
|
afterSha?: string;
|
|
9
|
+
/** Exact pass on the head (#2027). */
|
|
10
|
+
pass?: number;
|
|
11
|
+
/** Only a pass later than this one on the head (#2027). */
|
|
12
|
+
afterPass?: number;
|
|
9
13
|
timeoutMs: number;
|
|
10
14
|
};
|
|
11
15
|
export type PrOptions = {
|
package/dist/commands/pr.js
CHANGED
|
@@ -3,9 +3,17 @@ import { loadConfig } from "../config.js";
|
|
|
3
3
|
import { CommandError, REVIEW_EXIT } from "../errors.js";
|
|
4
4
|
import { ansi } from "../ui/ansi.js";
|
|
5
5
|
import { renderFindings } from "../ui/findings.js";
|
|
6
|
+
import { humanSummary } from "../ui/human-summary.js";
|
|
6
7
|
import { PrReviewPollTimeoutError, REVIEW_POLL_DEFAULT_TIMEOUT_MS, pollPrVortexReview, } from "./review-client.js";
|
|
7
8
|
import { parseAdoptTarget } from "./stack.js";
|
|
8
|
-
const PR_USAGE = "usage: mergestorm pr <owner/repo>#<n> [--json] [--wait] [--after-sha <sha>] [--timeout <s>]";
|
|
9
|
+
const PR_USAGE = "usage: mergestorm pr <owner/repo>#<n> [--json] [--wait] [--after-sha <sha>] [--pass <n>] [--after-pass <n>] [--timeout <s>]";
|
|
10
|
+
function parsePassFlag(flag, value) {
|
|
11
|
+
const parsed = Number(value);
|
|
12
|
+
if (!value?.trim() || !/^[0-9]+$/.test(value.trim()) || !Number.isSafeInteger(parsed) || parsed < 1) {
|
|
13
|
+
throw usageError(`${flag} requires a positive integer`);
|
|
14
|
+
}
|
|
15
|
+
return parsed;
|
|
16
|
+
}
|
|
9
17
|
function usageError(message = PR_USAGE) {
|
|
10
18
|
return new CommandError(message, REVIEW_EXIT.usage, "usage");
|
|
11
19
|
}
|
|
@@ -14,6 +22,8 @@ export function parsePrArgs(args, defaults = { format: "json" }) {
|
|
|
14
22
|
let format = defaults.format;
|
|
15
23
|
let wait = false;
|
|
16
24
|
let afterSha;
|
|
25
|
+
let pass;
|
|
26
|
+
let afterPass;
|
|
17
27
|
let timeoutMs = REVIEW_POLL_DEFAULT_TIMEOUT_MS;
|
|
18
28
|
for (let i = 0; i < args.length; i += 1) {
|
|
19
29
|
const arg = args[i];
|
|
@@ -42,6 +52,14 @@ export function parsePrArgs(args, defaults = { format: "json" }) {
|
|
|
42
52
|
}
|
|
43
53
|
continue;
|
|
44
54
|
}
|
|
55
|
+
if (arg === "--pass" || arg.startsWith("--pass=")) {
|
|
56
|
+
pass = parsePassFlag("--pass", arg === "--pass" ? args[++i] : arg.slice("--pass=".length));
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (arg === "--after-pass" || arg.startsWith("--after-pass=")) {
|
|
60
|
+
afterPass = parsePassFlag("--after-pass", arg === "--after-pass" ? args[++i] : arg.slice("--after-pass=".length));
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
45
63
|
if (arg === "--timeout" || arg.startsWith("--timeout=")) {
|
|
46
64
|
const value = arg === "--timeout" ? args[++i] : arg.slice("--timeout=".length);
|
|
47
65
|
const seconds = Number(value);
|
|
@@ -58,6 +76,12 @@ export function parsePrArgs(args, defaults = { format: "json" }) {
|
|
|
58
76
|
}
|
|
59
77
|
if (afterSha && !wait)
|
|
60
78
|
throw usageError("--after-sha requires --wait");
|
|
79
|
+
if (pass !== undefined && afterPass !== undefined) {
|
|
80
|
+
throw usageError("--pass and --after-pass are exclusive");
|
|
81
|
+
}
|
|
82
|
+
if ((pass !== undefined || afterPass !== undefined) && (!wait || !afterSha)) {
|
|
83
|
+
throw usageError("--pass and --after-pass require --wait --after-sha");
|
|
84
|
+
}
|
|
61
85
|
if (target.length < 1 || target.length > 2)
|
|
62
86
|
throw usageError();
|
|
63
87
|
let parsed;
|
|
@@ -67,7 +91,7 @@ export function parsePrArgs(args, defaults = { format: "json" }) {
|
|
|
67
91
|
catch {
|
|
68
92
|
throw usageError();
|
|
69
93
|
}
|
|
70
|
-
return { ...parsed, format, wait, afterSha, timeoutMs };
|
|
94
|
+
return { ...parsed, format, wait, afterSha, pass, afterPass, timeoutMs };
|
|
71
95
|
}
|
|
72
96
|
function printPretty(envelope) {
|
|
73
97
|
console.log(`Status: ${envelope.status}`);
|
|
@@ -78,8 +102,9 @@ function printPretty(envelope) {
|
|
|
78
102
|
console.log(`Finding count: ${envelope.finding_count}`);
|
|
79
103
|
if (envelope.skip_reason)
|
|
80
104
|
console.log(`Skip reason: ${envelope.skip_reason}`);
|
|
81
|
-
|
|
82
|
-
|
|
105
|
+
const summary = envelope.summary ? humanSummary(envelope.summary) : null;
|
|
106
|
+
if (summary)
|
|
107
|
+
console.log(summary);
|
|
83
108
|
renderFindings(envelope.findings, {
|
|
84
109
|
emptyMessage: envelope.status === "completed",
|
|
85
110
|
});
|
|
@@ -96,6 +121,8 @@ export async function cmdPr(args, opts = {}) {
|
|
|
96
121
|
timeoutMs: parsed.timeoutMs,
|
|
97
122
|
intervalMs: opts.pollIntervalMs,
|
|
98
123
|
afterSha: parsed.afterSha,
|
|
124
|
+
pass: parsed.pass,
|
|
125
|
+
afterPass: parsed.afterPass,
|
|
99
126
|
signal: opts.signal,
|
|
100
127
|
...opts.poll,
|
|
101
128
|
onTick(kind) {
|
|
@@ -116,7 +143,7 @@ export async function cmdPr(args, opts = {}) {
|
|
|
116
143
|
console.log("");
|
|
117
144
|
}
|
|
118
145
|
else {
|
|
119
|
-
envelope = await getPrVortexReview(parsed.owner, parsed.repo, parsed.prNumber, cfg, { signal: opts.signal });
|
|
146
|
+
envelope = await getPrVortexReview(parsed.owner, parsed.repo, parsed.prNumber, cfg, { signal: opts.signal, pass: parsed.pass, afterPass: parsed.afterPass });
|
|
120
147
|
}
|
|
121
148
|
if (parsed.format === "json") {
|
|
122
149
|
console.log(JSON.stringify(envelope, null, opts.mode === "shell" ? 2 : undefined));
|
|
@@ -66,7 +66,8 @@ export const COMMAND_REGISTRY = [
|
|
|
66
66
|
summary: "Latest Vortex review for a GitHub PR",
|
|
67
67
|
usage: [
|
|
68
68
|
"mergestorm pr <owner/repo>#<n> Fetch the latest Vortex PR review",
|
|
69
|
-
" [--json] [--wait] [--after-sha <sha>] [--timeout <s>]",
|
|
69
|
+
" [--json] [--wait] [--after-sha <sha>] [--pass <n>] [--after-pass <n>] [--timeout <s>]",
|
|
70
|
+
" --pass reads one attempt on the head; --after-pass waits for a later one (#2027)",
|
|
70
71
|
" Also accepts: mergestorm pr <owner/repo> <n>",
|
|
71
72
|
],
|
|
72
73
|
async run(args, ctx) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type PrVortexReview } from "../api.js";
|
|
1
|
+
import { type PrReviewPassSelector, type PrVortexReview } from "../api.js";
|
|
2
2
|
import type { Config } from "../config.js";
|
|
3
3
|
import type { ReviewJobRow } from "../ui/job-envelope.js";
|
|
4
4
|
export declare const REVIEW_POLL_INTERVAL_MS = 2000;
|
|
@@ -10,6 +10,8 @@ export declare const REVIEW_POLL_STRETCH_AFTER_MS = 60000;
|
|
|
10
10
|
export declare const REVIEW_POLL_STRETCH_SPAN_MS = 60000;
|
|
11
11
|
/** Same thread slug contract as POST /api/v1/reviews. */
|
|
12
12
|
export declare const REVIEW_THREAD_SLUG_RE: RegExp;
|
|
13
|
+
/** Same product limit as `api/src/v1/reviews.ts` `MAX_DIFF_BYTES`. */
|
|
14
|
+
export declare const MAX_REVIEW_DIFF_BYTES = 1000000;
|
|
13
15
|
export declare const MAX_CONTEXT_TEXT_CHARS = 60000;
|
|
14
16
|
export declare const MAX_CONTEXT_FILES = 10;
|
|
15
17
|
export declare const MAX_CONTEXT_FILE_CHARS = 16000;
|
|
@@ -52,7 +54,7 @@ export type LoadReviewContextOptions = {
|
|
|
52
54
|
contextFiles: string[];
|
|
53
55
|
cwd?: string;
|
|
54
56
|
readStdin?: () => Promise<string>;
|
|
55
|
-
/**
|
|
57
|
+
/** Bound contextFiles to this directory; defaults to the canonical repo root. */
|
|
56
58
|
sandboxCwd?: string;
|
|
57
59
|
};
|
|
58
60
|
/** Load --context / --context-file and enforce the API caps client-side. */
|
|
@@ -62,6 +64,11 @@ export declare function submitReview(cfg: Config, input: ReviewInput, opts?: Sub
|
|
|
62
64
|
row: ReviewJobRow;
|
|
63
65
|
retryAfterSeconds?: number;
|
|
64
66
|
}>;
|
|
67
|
+
/**
|
|
68
|
+
* Human copy for a failed POST /api/v1/reviews.
|
|
69
|
+
* nginx 413 is HTML, so `submitReview` stores `{}` and used to print `Failed: {}`.
|
|
70
|
+
*/
|
|
71
|
+
export declare function formatReviewSubmitError(status: number, body: unknown): string;
|
|
65
72
|
/** Exponential backoff with jitter in `[0.5, 1.0]` of the base delay. */
|
|
66
73
|
export declare function transientRetryWaitMs(input: {
|
|
67
74
|
retryAfterSeconds?: number;
|
|
@@ -98,7 +105,7 @@ export type PollReviewOptions = {
|
|
|
98
105
|
/** Test seam for backoff jitter; production uses `Math.random`. */
|
|
99
106
|
random?: () => number;
|
|
100
107
|
};
|
|
101
|
-
export type PollPrVortexReviewOptions = PollReviewOptions & {
|
|
108
|
+
export type PollPrVortexReviewOptions = PollReviewOptions & PrReviewPassSelector & {
|
|
102
109
|
afterSha?: string;
|
|
103
110
|
};
|
|
104
111
|
/** Poll the DB-only PR review endpoint until the requested pass is resting. */
|
|
@@ -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
|
|
29
|
-
const
|
|
30
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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(
|
|
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
|
-
|
|
246
|
-
|
|
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
|
-
|
|
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)
|
package/dist/commands/review.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
411
|
-
|
|
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
|
|
416
|
-
const
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
}
|
package/dist/commands/stack.js
CHANGED
|
@@ -519,6 +519,12 @@ export async function cmdStackCreate(args, deps = {}) {
|
|
|
519
519
|
}
|
|
520
520
|
export async function cmdStackSubmit(args, deps = {}) {
|
|
521
521
|
const { extend, autoLand, autoReview, autoPatch, asJson } = parseStackSubmitArgs(args);
|
|
522
|
+
const progress = (message) => {
|
|
523
|
+
if (asJson)
|
|
524
|
+
console.error(message);
|
|
525
|
+
else
|
|
526
|
+
console.log(message);
|
|
527
|
+
};
|
|
522
528
|
const cwd = deps.cwd ?? process.cwd();
|
|
523
529
|
const gitTopLevelFn = deps.gitTopLevel ?? gitTopLevel;
|
|
524
530
|
const loadStackMetaFn = deps.loadStackMeta ?? loadStackMeta;
|
|
@@ -608,7 +614,7 @@ export async function cmdStackSubmit(args, deps = {}) {
|
|
|
608
614
|
if (!branchExistsFn(layer.branch, cwd)) {
|
|
609
615
|
throw new CommandError(`Stack layer branch missing locally: ${layer.branch}`);
|
|
610
616
|
}
|
|
611
|
-
|
|
617
|
+
progress(ansi.dim(` Pushing ${layer.branch} …`));
|
|
612
618
|
try {
|
|
613
619
|
pushBranchFn(layer.branch, cwd);
|
|
614
620
|
}
|
|
@@ -625,7 +631,7 @@ export async function cmdStackSubmit(args, deps = {}) {
|
|
|
625
631
|
const openLayerPr = (branch, base) => {
|
|
626
632
|
const existing = findOpenPrNumberFn(owner, repo, branch, cwd);
|
|
627
633
|
if (existing != null) {
|
|
628
|
-
|
|
634
|
+
progress(ansi.dim(` PR #${existing} already open for ${branch}`));
|
|
629
635
|
return { prNumber: existing, created: false };
|
|
630
636
|
}
|
|
631
637
|
const aheadRef = isMgParkBranch(base) ? `origin/${base}` : base;
|
|
@@ -653,7 +659,7 @@ export async function cmdStackSubmit(args, deps = {}) {
|
|
|
653
659
|
}
|
|
654
660
|
const title = tipCommitSubjectFn(branch, cwd);
|
|
655
661
|
const body = buildPrBodyFn(tipCommitMessageFn(branch, cwd));
|
|
656
|
-
|
|
662
|
+
progress(ansi.dim(` Opening PR ${branch} → ${base} …`));
|
|
657
663
|
return {
|
|
658
664
|
prNumber: createPrFn({
|
|
659
665
|
owner,
|
|
@@ -679,7 +685,7 @@ export async function cmdStackSubmit(args, deps = {}) {
|
|
|
679
685
|
let adoptData = null;
|
|
680
686
|
let stackId = plans.find((plan) => plan.stackId)?.stackId ?? null;
|
|
681
687
|
const registerPr = async (prNumber) => {
|
|
682
|
-
|
|
688
|
+
progress(ansi.dim(` Registering stack via import (${owner}/${repo}#${prNumber}) …`));
|
|
683
689
|
const body = await adoptStackFn(owner, repo, prNumber, cfg, policy);
|
|
684
690
|
if (typeof body !== "object" || body === null) {
|
|
685
691
|
throw new CommandError("Unexpected response shape from adopt API");
|
|
@@ -703,7 +709,7 @@ export async function cmdStackSubmit(args, deps = {}) {
|
|
|
703
709
|
if (!stackId) {
|
|
704
710
|
throw new CommandError("Cannot mint a park freeze before the stack is registered. Open layers 1–2 first, then retry.");
|
|
705
711
|
}
|
|
706
|
-
|
|
712
|
+
progress(ansi.dim(` Ensuring upper-park freeze for stack ${stackId} …`));
|
|
707
713
|
const park = await ensureUpperParkFn(stackId, cfg);
|
|
708
714
|
for (const plan of pendingPark) {
|
|
709
715
|
const { prNumber, created } = openLayerPr(plan.branch, park.freezeBranch);
|
package/dist/commands/status.js
CHANGED
|
@@ -5,6 +5,7 @@ import { canBrowse, formatJobDetailLines } from "./browse.js";
|
|
|
5
5
|
import { CommandError, DetachedError, REVIEW_EXIT, isCommandErrorCode, } from "../errors.js";
|
|
6
6
|
import { ansi } from "../ui/ansi.js";
|
|
7
7
|
import { renderFindings } from "../ui/findings.js";
|
|
8
|
+
import { humanSummary } from "../ui/human-summary.js";
|
|
8
9
|
import { toReviewJobEnvelope, } from "../ui/job-envelope.js";
|
|
9
10
|
import { REVIEW_POLL_DEFAULT_TIMEOUT_MS, ReviewPollTimeoutError, pollReview, } from "./review-client.js";
|
|
10
11
|
const STATUS_USAGE = "usage: mergestorm status <job_id> [--json] [--wait] [--timeout seconds]";
|
|
@@ -71,8 +72,9 @@ function printPretty(row) {
|
|
|
71
72
|
if (envelope.credits) {
|
|
72
73
|
console.log(`Credits: ${envelope.credits.standard} standard`);
|
|
73
74
|
}
|
|
74
|
-
|
|
75
|
-
|
|
75
|
+
const summary = envelope.summary ? humanSummary(envelope.summary) : null;
|
|
76
|
+
if (summary)
|
|
77
|
+
console.log(summary);
|
|
76
78
|
if (envelope.error)
|
|
77
79
|
console.log(ansi.red(`Error: ${envelope.error}`));
|
|
78
80
|
renderFindings(envelope.findings, {
|
package/dist/commands/whoami.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getMe } from "../api.js";
|
|
2
|
-
import { apiBase, configPath, loadConfig, resolveApiKey
|
|
2
|
+
import { apiBase, configPath, loadConfig, resolveApiKey } from "../config.js";
|
|
3
3
|
import { CommandError } from "../errors.js";
|
|
4
4
|
import { ansi } from "../ui/ansi.js";
|
|
5
5
|
import { present } from "../ui/present.js";
|
|
@@ -16,14 +16,17 @@ export async function cmdWhoami(args, opts = {}) {
|
|
|
16
16
|
return;
|
|
17
17
|
}
|
|
18
18
|
const me = await getMe(cfg);
|
|
19
|
+
if (!me) {
|
|
20
|
+
throw new CommandError("Live account details unavailable. Could not verify account with /me.");
|
|
21
|
+
}
|
|
19
22
|
const payload = {
|
|
20
|
-
key_prefix: me
|
|
21
|
-
key_name: me
|
|
22
|
-
plan_key: me
|
|
23
|
-
plan_label_key: me
|
|
23
|
+
key_prefix: me.key.prefix,
|
|
24
|
+
key_name: me.key.name ?? null,
|
|
25
|
+
plan_key: me.plan_key ?? null,
|
|
26
|
+
plan_label_key: me.plan_label_key ?? null,
|
|
24
27
|
api_base: apiBase(cfg),
|
|
25
28
|
config_path: configPath(),
|
|
26
|
-
usage: me
|
|
29
|
+
usage: me.usage ?? null,
|
|
27
30
|
};
|
|
28
31
|
if (asJson) {
|
|
29
32
|
const text = JSON.stringify(payload, null, 2);
|
|
@@ -42,8 +45,5 @@ export async function cmdWhoami(args, opts = {}) {
|
|
|
42
45
|
}
|
|
43
46
|
lines.push(` API ${payload.api_base}`);
|
|
44
47
|
lines.push(` Config ${payload.config_path}`);
|
|
45
|
-
if (!me) {
|
|
46
|
-
lines.push(ansi.dim(" (live account details unavailable — older API or offline)"));
|
|
47
|
-
}
|
|
48
48
|
await present("Account", lines);
|
|
49
49
|
}
|
package/dist/git.js
CHANGED
|
@@ -48,7 +48,7 @@ export async function changedNamesInSandbox(base, head, cwd = process.cwd(), san
|
|
|
48
48
|
// even though their contents cannot be read from the working tree for upload.
|
|
49
49
|
// -z disables core.quotePath quoting and NUL-terminates names, so paths with
|
|
50
50
|
// spaces or non-ASCII characters survive the split verbatim.
|
|
51
|
-
const names = git(["diff", "--name-only", "-z", "--diff-filter=ACMRDT", `${base}...${head}`], cwd)
|
|
51
|
+
const names = git(["diff", "--no-ext-diff", "--no-textconv", "--name-only", "-z", "--diff-filter=ACMRDT", `${base}...${head}`], cwd)
|
|
52
52
|
.split("\0")
|
|
53
53
|
.filter(Boolean);
|
|
54
54
|
const kept = [];
|
package/dist/stack-dto.d.ts
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
export type RestackError = {
|
|
2
|
+
kind: "rebase_conflict" | "checkout_failed" | "head_unresolved" | "push_failed";
|
|
3
|
+
detail: string;
|
|
4
|
+
headSha: string;
|
|
5
|
+
attemptedAt: string;
|
|
6
|
+
attempts: number;
|
|
7
|
+
backupRef: string | null;
|
|
8
|
+
};
|
|
1
9
|
/**
|
|
2
10
|
* Wire types returned by the stack list/enrich HTTP endpoints.
|
|
3
11
|
*
|
|
@@ -7,8 +15,12 @@
|
|
|
7
15
|
export type StackBranchState = "clean" | "needs_restack" | "restacking" | "conflict" | "merged" | "closed";
|
|
8
16
|
export type StackCiStatus = "success" | "pending" | "failure" | "neutral" | "none" | "unknown";
|
|
9
17
|
export type StackReviewStatus = "approved" | "changes_requested" | "reviewed" | "failed" | "none" | "unknown";
|
|
10
|
-
/**
|
|
11
|
-
|
|
18
|
+
/**
|
|
19
|
+
* `failed` is the newest Vortex `pr_reviews` row. Work paints Review failed.
|
|
20
|
+
* `incomplete` is a posted verdict whose head still has unreviewed files
|
|
21
|
+
* (#1947): Work paints Incomplete, never Reviewed or all clear.
|
|
22
|
+
*/
|
|
23
|
+
export type StackVortexStatus = "reviewing" | "seam_pending" | "all_clear" | "findings" | "throttled" | "failed" | "incomplete";
|
|
12
24
|
export type StackCycloneStatus = "patching" | "awaiting_fix";
|
|
13
25
|
export type StackTempestStatus = "reviewing" | "findings" | "clear" | "failed" | "stale";
|
|
14
26
|
export type StackAgentName = "vortex" | "cyclone" | "tempest";
|
|
@@ -38,6 +50,15 @@ export type StackAgentRun = {
|
|
|
38
50
|
* Only on resting Vortex `findings` runs.
|
|
39
51
|
*/
|
|
40
52
|
findingCount?: number | null;
|
|
53
|
+
/**
|
|
54
|
+
* File coverage at the posted head from `vortex_pr_review_state.file_states`
|
|
55
|
+
* (#1947): files reviewed at this head over all changed files. Only on
|
|
56
|
+
* resting Vortex `incomplete` runs, so the chip tooltip can say 21/32.
|
|
57
|
+
*/
|
|
58
|
+
coverage?: {
|
|
59
|
+
reviewed: number;
|
|
60
|
+
total: number;
|
|
61
|
+
} | null;
|
|
41
62
|
};
|
|
42
63
|
export type StackLayerChecks = {
|
|
43
64
|
total: number;
|
|
@@ -118,6 +139,7 @@ export type StackLayerDto = {
|
|
|
118
139
|
*/
|
|
119
140
|
agentRuns?: StackAgentRun[];
|
|
120
141
|
conflictDetail: string | null;
|
|
142
|
+
restackError?: RestackError | null;
|
|
121
143
|
lastRestackedSha: string | null;
|
|
122
144
|
/** GitHub PR mergeability from enrich; null when unknown. */
|
|
123
145
|
mergeable: boolean | null;
|
|
@@ -207,6 +229,7 @@ export type StackUnitDto = {
|
|
|
207
229
|
*/
|
|
208
230
|
landPr: StackLayerDto | null;
|
|
209
231
|
};
|
|
232
|
+
export type CycloneOwnerMatch = "same" | "different" | "none" | "lookup_failed";
|
|
210
233
|
export type StackDto = {
|
|
211
234
|
id: string;
|
|
212
235
|
owner: string;
|
|
@@ -234,6 +257,26 @@ export type StackDto = {
|
|
|
234
257
|
* account `auto_patch_enabled` flag; a boolean wins in both directions.
|
|
235
258
|
*/
|
|
236
259
|
autoPatchOverride?: boolean | null;
|
|
260
|
+
/**
|
|
261
|
+
* Bearer / stack-row user. Cyclone apply follows this user's auto-patch
|
|
262
|
+
* policy and credits for an adopted stack (HOUSE-1), even when
|
|
263
|
+
* `cycloneInstallUserId` is a different Mergestorm account on a shared repo
|
|
264
|
+
* (LOOP-5).
|
|
265
|
+
*/
|
|
266
|
+
keyUserId?: string;
|
|
267
|
+
/**
|
|
268
|
+
* Cyclone GitHub App install user for this owner/repo, when resolved. Its
|
|
269
|
+
* installation clones and pushes; it does not decide auto-patch for another
|
|
270
|
+
* user's stack.
|
|
271
|
+
*/
|
|
272
|
+
cycloneInstallUserId?: string | null;
|
|
273
|
+
/**
|
|
274
|
+
* `same`: the only Cyclone install user is the key user.
|
|
275
|
+
* `different`: a Cyclone install user is not the key user (or more than one).
|
|
276
|
+
* `none`: no Cyclone install monitors this repo.
|
|
277
|
+
* `lookup_failed`: the comparison could not be loaded. Fail closed.
|
|
278
|
+
*/
|
|
279
|
+
cycloneOwnerMatch?: CycloneOwnerMatch;
|
|
237
280
|
layers: StackLayerDto[];
|
|
238
281
|
/** Present when a review-unit row exists for this stack. */
|
|
239
282
|
unit?: StackUnitDto;
|
package/dist/stack-dto.js
CHANGED
package/dist/ui/banner.js
CHANGED
|
@@ -84,6 +84,9 @@ const WHATS_NEW = {
|
|
|
84
84
|
"0.3.16": [
|
|
85
85
|
"PR loop: verify each finding, prefer a small diff, post mergestorm-loop: dismiss on skip",
|
|
86
86
|
],
|
|
87
|
+
"0.3.17": [
|
|
88
|
+
"nginx 413 names the upload limit instead of Failed: {}",
|
|
89
|
+
],
|
|
87
90
|
};
|
|
88
91
|
function densityFromVariant(variant) {
|
|
89
92
|
if (variant === "mini" || variant === "nano")
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
const TOOL_PAYLOAD_KEYS = new Set([
|
|
2
|
+
"function_call",
|
|
3
|
+
"tool",
|
|
4
|
+
"tool_call",
|
|
5
|
+
"tool_calls",
|
|
6
|
+
]);
|
|
7
|
+
function parseJsonContainer(value) {
|
|
8
|
+
const trimmed = value.trim();
|
|
9
|
+
if (!((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
|
|
10
|
+
(trimmed.startsWith("[") && trimmed.endsWith("]")))) {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(trimmed);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function looksLikeToolPayload(value) {
|
|
21
|
+
if (Array.isArray(value))
|
|
22
|
+
return value.some(looksLikeToolPayload);
|
|
23
|
+
if (!value || typeof value !== "object")
|
|
24
|
+
return false;
|
|
25
|
+
const record = value;
|
|
26
|
+
const keys = Object.keys(record);
|
|
27
|
+
if (keys.some((key) => TOOL_PAYLOAD_KEYS.has(key.toLowerCase())))
|
|
28
|
+
return true;
|
|
29
|
+
const lower = new Set(keys.map((key) => key.toLowerCase()));
|
|
30
|
+
if (record.type === "tool_use" && lower.has("name") && lower.has("input"))
|
|
31
|
+
return true;
|
|
32
|
+
if (lower.has("verdict") && lower.has("comments"))
|
|
33
|
+
return true;
|
|
34
|
+
return Object.values(record).some(looksLikeToolPayload);
|
|
35
|
+
}
|
|
36
|
+
function matchingJsonEnd(text, start) {
|
|
37
|
+
const open = text[start];
|
|
38
|
+
if (open !== "{" && open !== "[")
|
|
39
|
+
return null;
|
|
40
|
+
const stack = [open];
|
|
41
|
+
let quoted = false;
|
|
42
|
+
let escaped = false;
|
|
43
|
+
for (let i = start + 1; i < text.length; i += 1) {
|
|
44
|
+
const char = text[i];
|
|
45
|
+
if (quoted) {
|
|
46
|
+
if (escaped)
|
|
47
|
+
escaped = false;
|
|
48
|
+
else if (char === "\\")
|
|
49
|
+
escaped = true;
|
|
50
|
+
else if (char === '"')
|
|
51
|
+
quoted = false;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (char === '"') {
|
|
55
|
+
quoted = true;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (char === "{" || char === "[")
|
|
59
|
+
stack.push(char);
|
|
60
|
+
else if (char === "}" || char === "]") {
|
|
61
|
+
const expected = char === "}" ? "{" : "[";
|
|
62
|
+
if (stack.pop() !== expected)
|
|
63
|
+
return null;
|
|
64
|
+
if (stack.length === 0)
|
|
65
|
+
return i + 1;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
function fencedLines(text) {
|
|
71
|
+
const lines = text.split("\n");
|
|
72
|
+
const protectedLines = new Set();
|
|
73
|
+
const openFences = [];
|
|
74
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
75
|
+
if (/^\s*```[a-z0-9_-]+\s*$/i.test(lines[i])) {
|
|
76
|
+
openFences.push(i);
|
|
77
|
+
}
|
|
78
|
+
else if (/^\s*```\s*$/.test(lines[i])) {
|
|
79
|
+
if (openFences.length > 0) {
|
|
80
|
+
const start = openFences.pop();
|
|
81
|
+
for (let line = start; line <= i; line += 1)
|
|
82
|
+
protectedLines.add(line);
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
openFences.push(i);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return protectedLines;
|
|
90
|
+
}
|
|
91
|
+
function stripBoundaryPayload(text) {
|
|
92
|
+
let result = text;
|
|
93
|
+
let changed = true;
|
|
94
|
+
while (changed) {
|
|
95
|
+
changed = false;
|
|
96
|
+
const protectedLines = fencedLines(result);
|
|
97
|
+
for (let start = 0; start < result.length; start += 1) {
|
|
98
|
+
if (result[start] !== "{" && result[start] !== "[")
|
|
99
|
+
continue;
|
|
100
|
+
if (protectedLines.has(result.slice(0, start).split("\n").length - 1))
|
|
101
|
+
continue;
|
|
102
|
+
const end = matchingJsonEnd(result, start);
|
|
103
|
+
if (end == null)
|
|
104
|
+
continue;
|
|
105
|
+
const parsed = parseJsonContainer(result.slice(start, end));
|
|
106
|
+
if (parsed !== undefined &&
|
|
107
|
+
looksLikeToolPayload(parsed)) {
|
|
108
|
+
const before = result.slice(0, start);
|
|
109
|
+
const after = result.slice(end);
|
|
110
|
+
result = `${before}${before.endsWith("\n") && after.startsWith("\n") ? after.slice(1) : after}`;
|
|
111
|
+
changed = true;
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
function stripOrphanLines(text) {
|
|
119
|
+
const lines = text.split("\n");
|
|
120
|
+
const keptLines = new Set();
|
|
121
|
+
const protectedLines = fencedLines(text);
|
|
122
|
+
for (const line of protectedLines)
|
|
123
|
+
keptLines.add(line);
|
|
124
|
+
for (let start = 0; start < text.length; start += 1) {
|
|
125
|
+
if (text[start] !== "{" && text[start] !== "[")
|
|
126
|
+
continue;
|
|
127
|
+
const end = matchingJsonEnd(text, start);
|
|
128
|
+
if (end == null || parseJsonContainer(text.slice(start, end)) === undefined)
|
|
129
|
+
continue;
|
|
130
|
+
const i = text.slice(0, start).split("\n").length - 1;
|
|
131
|
+
const lastLine = text.slice(0, end).split("\n").length - 1;
|
|
132
|
+
for (let line = i; line <= lastLine; line += 1) {
|
|
133
|
+
keptLines.add(line);
|
|
134
|
+
if (i !== lastLine)
|
|
135
|
+
protectedLines.add(line);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return lines
|
|
139
|
+
.filter((line, i) => protectedLines.has(i) ||
|
|
140
|
+
(parseJsonContainer(line) === undefined &&
|
|
141
|
+
(keptLines.has(i) || !/^\s*(?:```|[{}])\s*$/.test(line))))
|
|
142
|
+
.join("\n");
|
|
143
|
+
}
|
|
144
|
+
/** Remove tool/LLM JSON that is unsafe to show in human summary output. */
|
|
145
|
+
export function humanSummary(summary) {
|
|
146
|
+
let result = summary.replace(/\r\n?/g, "\n");
|
|
147
|
+
result = result.replace(/```json[ \t]*\n([\s\S]*?)```/gi, (block, body) => parseJsonContainer(body) === undefined ? block : "");
|
|
148
|
+
result = stripBoundaryPayload(result);
|
|
149
|
+
result = stripOrphanLines(result)
|
|
150
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
151
|
+
.trim();
|
|
152
|
+
return result || null;
|
|
153
|
+
}
|
package/dist/ui/usage.d.ts
CHANGED
|
@@ -22,8 +22,6 @@ export declare function nextUtcMonthStart(now?: Date): string;
|
|
|
22
22
|
export declare function formatResetLabel(iso: string | null | undefined, now?: Date): string;
|
|
23
23
|
/** How long until `iso` (the API `resets_at`). Empty when the date is unusable. */
|
|
24
24
|
export declare function formatDaysLeft(iso: string | null | undefined, now?: Date): string;
|
|
25
|
-
/** Alias kept for callers that still import `formatResetDate`. */
|
|
26
|
-
export declare function formatResetDate(iso: string | null | undefined): string;
|
|
27
25
|
/** Bar cells so ` [bar] 100% used` stays inside the terminal. */
|
|
28
26
|
export declare function usageBarWidth(columns: number): number;
|
|
29
27
|
export type UsagePanelJob = {
|
|
@@ -38,6 +36,7 @@ export type UsagePanelInput = {
|
|
|
38
36
|
plan: string;
|
|
39
37
|
used: number;
|
|
40
38
|
limit: number | null;
|
|
39
|
+
bonusRemaining?: number;
|
|
41
40
|
resetsAt: string | null | undefined;
|
|
42
41
|
jobs: UsagePanelJob[];
|
|
43
42
|
/** When the jobs fetch failed, show this instead of "No review jobs yet." */
|
package/dist/ui/usage.js
CHANGED
|
@@ -83,10 +83,6 @@ export function formatDaysLeft(iso, now = new Date()) {
|
|
|
83
83
|
return `${hours}h left`;
|
|
84
84
|
return "resets soon";
|
|
85
85
|
}
|
|
86
|
-
/** Alias kept for callers that still import `formatResetDate`. */
|
|
87
|
-
export function formatResetDate(iso) {
|
|
88
|
-
return formatResetLabel(iso);
|
|
89
|
-
}
|
|
90
86
|
/** Bar cells so ` [bar] 100% used` stays inside the terminal. */
|
|
91
87
|
export function usageBarWidth(columns) {
|
|
92
88
|
return Math.max(8, frameWidth(80, columns) - 13);
|
|
@@ -112,6 +108,9 @@ export function formatUsagePanel(input) {
|
|
|
112
108
|
` ${identity}`,
|
|
113
109
|
` ${bar}`,
|
|
114
110
|
` ${formatResetLabel(input.resetsAt)}`,
|
|
111
|
+
...(input.bonusRemaining == null
|
|
112
|
+
? []
|
|
113
|
+
: [` Bonus credits: ${input.bonusRemaining} remaining`]),
|
|
115
114
|
"",
|
|
116
115
|
];
|
|
117
116
|
if (input.jobs.length === 0) {
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -62,11 +62,11 @@ Default: omit `router` (account setting).
|
|
|
62
62
|
|
|
63
63
|
## Submit
|
|
64
64
|
|
|
65
|
-
Call `review_submit` on the repo cwd (git collect when `diff` is omitted). Pass one or two sentences of intent in `context`. Pass an ADR or spec as `context_files` when one exists in the repo under review
|
|
65
|
+
Call `review_submit` with `wait: false` (the default) on the repo cwd (git collect when `diff` is omitted). Pass one or two sentences of intent in `context`. Pass an ADR or spec as `context_files` when one exists in the repo under review, never from outside it. Do not paste the diff into context.
|
|
66
66
|
|
|
67
|
-
Optional: `thread`, `idempotency_key`, `wait`, `timeout_s`.
|
|
67
|
+
Optional: `thread`, `idempotency_key`, `wait`, `timeout_s`. Submit returns without waiting. Record `job_id` as soon as it returns, then loop `review_wait` with that `job_id` and `timeout_s: 45` until the job finishes. Each call is one 45s slice. On timeout, call `review_wait` again with the same `job_id`. Do not pass 300; hosts drop long MCP calls. Explicit `wait: true` also defaults to one 45s slice.
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
Call `review_list` before any resubmit, including when a submit or wait call is interrupted. Resume the existing job with `review_get` or `review_wait`; do not create a second job because a slice died or the submit response was lost.
|
|
70
70
|
|
|
71
71
|
Status handling:
|
|
72
72
|
|
|
@@ -19,29 +19,35 @@ mg skill install --claude --cursor
|
|
|
19
19
|
|
|
20
20
|
1. There is an already-open GitHub PR on a Vortex-monitored repo. No PR, wrong skill.
|
|
21
21
|
2. Auto-patch (Cyclone) must be off for this PR so you are the only patcher. Do not take anyone's word for it: the settings API and the PR's stack are the source of truth, and step 1 of the loop verifies them before anything else runs. Cyclone is off for the PR when its adopted stack has `autoPatchOverride` `false`, or when that override is unset and the account `auto_patch_enabled` is `false`.
|
|
22
|
-
3.
|
|
22
|
+
3. The Bearer key and the Cyclone GitHub App install must be the same Mergestorm account. `stack_list` / `stack_status` expose `keyUserId`, `cycloneInstallUserId`, and `cycloneOwnerMatch`. Cyclone apply always uses the install account, not the key. If those identities differ, your `settings_get` mute reading is about the wrong person.
|
|
23
|
+
4. `whoami` works on the `mergestorm` MCP server (or `mg whoami --json` exits 0). Otherwise stop and ask for install/login.
|
|
23
24
|
|
|
24
25
|
## Loop (in order)
|
|
25
26
|
|
|
26
|
-
1.
|
|
27
|
+
1. Find the PR's stack with `stack_list` (the stack whose `layers` contain this PR's `prNumber` for this owner/repo). If you already have its `stack_id`, `stack_status` is the same payload.
|
|
28
|
+
- If there is no stack and the human asked you to mute Cyclone or continue the loop: call `stack_adopt` with `{ owner, repo, pr_number, auto_patch: false }` (equivalent to `mg stack adopt … --auto-patch off`). Then call `stack_status` with the returned `result.stack.id`, confirm this PR is in the stack and `autoPatchOverride` is `false`. If adoption or verification fails, refuse the loop. Otherwise continue through the ownership check below and the remaining loop steps; do not stop and ask the human to adopt. Do not turn account auto-patch off.
|
|
29
|
+
- Adoption does not fix installer ≠ key. After adoption, the same `cycloneOwnerMatch` refusal below still applies.
|
|
30
|
+
- Read `cycloneOwnerMatch` on that stack. If it is missing, `lookup_failed`, or `different`: refuse the loop. Do not wait, patch, or push. Tell the human the API key and the Cyclone GitHub App install are different Mergestorm accounts (or the comparison could not be loaded). Cyclone still patches as the installer. Do not turn account auto-patch off, and do not set `auto_patch: false` to paper over this.
|
|
31
|
+
- If it is `same` or `none`: continue. `none` means no Cyclone install monitors this repo.
|
|
32
|
+
2. Call `settings_get` and read `auto_patch_enabled`.
|
|
27
33
|
- If `settings_get` errors or its result has no `auto_patch_enabled`: refuse the loop. Do not wait, patch, or push; tell the human that Cyclone auto-patch could not be verified.
|
|
28
|
-
- If it is `false`:
|
|
29
|
-
- If it is `true`:
|
|
30
|
-
- If
|
|
31
|
-
- If the
|
|
34
|
+
- If it is `false`: read `autoPatchOverride` on the stack from step 1. If it is `true`, refuse unless the human asked you to turn Cyclone off for this PR; if they did, use the `stack_set { auto_patch: false }` path below. Otherwise continue. You are the only patcher.
|
|
35
|
+
- If it is `true`: read `autoPatchOverride` on that same stack.
|
|
36
|
+
- If `autoPatchOverride` is `false`: continue. Cyclone is pinned off for this stack.
|
|
37
|
+
- If the override is not `false` and the human asked you to turn Cyclone off for this PR: call `stack_set` with `{ stack_id, auto_patch: false }` on that stack, call `stack_status` with that `stack_id`, and confirm `autoPatchOverride` now reads `false`. Leave the account `auto_patch_enabled` unchanged; other PRs keep their own policy.
|
|
32
38
|
- Otherwise: refuse the loop. Do not wait, patch, or push; tell the human Cyclone auto-patch is still on for this PR and that `stack_set` with `auto_patch: false` on its stack turns it off for this PR only.
|
|
33
39
|
- Never set `auto_patch_enabled` with `settings_set` from this skill, in either direction, and never set `auto_patch: true` on a stack.
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
40
|
+
3. Call `review_wait_pr` with the PR's owner, repo, pr_number, and `timeout_s` 45 (`review_get_pr` works for a first look at the resting state). A Vortex pass often takes several minutes. If status is `in_progress`, call `review_wait_pr` again with the same args. Do not pass a 300s timeout; hosts drop long MCP calls. Review identity is SHA + pass: every envelope carries `head_sha` and `pass` (1 for the first review on a head; a re-review on the same head is the next pass, its own row). Save the `pass` of every resting envelope you act on together with its `head_sha`.
|
|
41
|
+
4. Confirm the envelope's `head_sha` is the commit you have checked out as the PR head (compare its leading 7+ hex characters with `git rev-parse HEAD`). If it is not, someone else pushed after this pass and its findings are not yours to patch: do not edit. Fetch and check out the PR head, then go to step 8 with `after_sha` set to that head.
|
|
42
|
+
5. Read `findings.inline` and `findings.offDiff` (or `off_diff`) sorted by severity. For each finding:
|
|
37
43
|
- Verify it against the current checkout. Do not treat the finding text as proven.
|
|
38
44
|
- Prefer the smallest correct patch. Do not refactor around a finding.
|
|
39
45
|
- Patch concrete bugs. A chat-only explanation is not a dismiss.
|
|
40
|
-
- If you skip a finding (not reproducible, policy fork, or needs a human), post a public GitHub PR comment before you stop. First line: `mergestorm-loop: dismiss`. Then one line per skipped finding (path, severity, why).
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
46
|
+
- If you skip a finding (not reproducible, policy fork, or needs a human), post a public GitHub PR comment before you stop. First line: `mergestorm-loop: dismiss`. Then one line per skipped finding (path, severity, why). That first line is the only dismiss marker Vortex reads.
|
|
47
|
+
6. Patch on the PR head branch only.
|
|
48
|
+
7. Push that head branch with an ordinary push. If the push is rejected because the remote branch moved, another patcher already pushed for this pass. Do not rebase, do not force, do not retry the push: stop and tell the human that a second patcher is active on this PR.
|
|
49
|
+
8. Call `review_wait_pr` again with `after_sha` set to the SHA you just pushed (7+ hex) and `timeout_s` 45. If the last envelope you acted on has that same `head_sha` (no new commit, for example a re-review requested on the same head), also pass `after_pass` set to that envelope's saved `pass`, so the wait cannot hand you that attempt again. For a freshly pushed SHA omit `after_pass`; its first pass is 1. Keep `after_sha` and `after_pass` unchanged across 45s timeout retries; never replace `after_pass` with the `pass` of an in-progress envelope. `review_get_pr` accepts the same `after_sha`, `pass`, and `after_pass` filters. An envelope whose `head_sha` is an older commit is a previous pass, not yours; keep waiting.
|
|
50
|
+
9. Repeat from step 4 until a stop rule fires.
|
|
45
51
|
|
|
46
52
|
## Stop rules
|
|
47
53
|
|
|
@@ -50,15 +56,16 @@ Map these to `mergestorm.pr_review/v1` envelope fields; do not invent statuses.
|
|
|
50
56
|
- `finding_count` is 0 (empty inline and offDiff): stop. Tell the human the PR is clear and that Auto land owns landing. Never merge it yourself.
|
|
51
57
|
- status is `rate_limited`, or `patch_policy.mode` is `"hold"`: surface the findings to the human and stop. Do not patch.
|
|
52
58
|
- status is `failed`: report to the human. Do not keep pushing to retrigger.
|
|
53
|
-
- status is `in_progress` and the envelope has a `head_sha`: the pass is still running; call `review_wait_pr` again with the same `after_sha`.
|
|
54
|
-
- status is `in_progress` and the envelope has no `head_sha`: the wait timed out before Vortex wrote a pass for your SHA. Call `review_wait_pr` once more with the same `after_sha`. If it comes back the same way a second time in a row, stop and tell the human that Vortex did not pick up the push. Do not push again to retrigger.
|
|
59
|
+
- status is `in_progress` and the envelope has a `head_sha`: the pass is still running; call `review_wait_pr` again with the same `after_sha` and the same `after_pass`.
|
|
60
|
+
- status is `in_progress` and the envelope has no `head_sha`: the wait timed out before Vortex wrote a pass for your SHA. Call `review_wait_pr` once more with the same `after_sha` and the same `after_pass`. If it comes back the same way a second time in a row, stop and tell the human that Vortex did not pick up the push. Do not push again to retrigger.
|
|
55
61
|
- status is `skipped`, `stopped`, `trial_expired`, or `synced`: surface the status and reason to the human and stop. Do not patch or push.
|
|
56
62
|
|
|
57
63
|
## Never
|
|
58
64
|
|
|
59
65
|
- submit a local review job, or call the job-id tools `review_wait` / `review_get` here. Local jobs are `mergestorm-review`'s world: they cost credits and do not fire Vortex on the PR.
|
|
60
|
-
- land, merge, enqueue, or touch stack authoring or the merge queue
|
|
61
|
-
- change the account `auto_patch_enabled` from this skill, for any reason; Cyclone is muted per stack with `stack_set`, never per account
|
|
66
|
+
- land, merge, enqueue, or touch stack authoring beyond adopting the existing PR, or the merge queue
|
|
67
|
+
- change the account `auto_patch_enabled` from this skill, for any reason; Cyclone is muted per stack with `stack_adopt` or `stack_set`, never per account
|
|
68
|
+
- treat the key user's mute or `auto_patch_enabled` as covering a different Cyclone install user
|
|
62
69
|
- set `auto_patch: true` on any stack
|
|
63
70
|
- push any branch other than the PR head
|
|
64
71
|
- force-push, or rebase and retry a push that was rejected because the remote moved
|
|
@@ -75,10 +82,10 @@ Verify auto-patch with the CLI instead of `settings_get`:
|
|
|
75
82
|
mg settings --json
|
|
76
83
|
```
|
|
77
84
|
|
|
78
|
-
Read `auto_patch_enabled` from stdout. If the command errors or the result has no `auto_patch_enabled`, refuse the loop and tell the human that Cyclone auto-patch could not be verified. Run `mg stack list --json` and find the stack whose layers contain this PR. If its `autoPatchOverride` is `false`, continue. If it is unset and the account flag is `false`, continue. If it is `true`, or it is unset while the account flag is `true`, refuse unless the human asked you to turn Cyclone off for this PR; only then run `mg stack set <stack-id> --auto-patch off`
|
|
85
|
+
Read `auto_patch_enabled` from stdout. If the command errors or the result has no `auto_patch_enabled`, refuse the loop and tell the human that Cyclone auto-patch could not be verified. Run `mg stack list --json` and find the stack whose layers contain this PR. If there is no stack, or its `cycloneOwnerMatch` is missing, `lookup_failed`, or `different`, refuse the loop. Tell the human the API key and the Cyclone install are different accounts (or adopt first, or the lookup failed). Do not run `mg settings --auto-patch off`. If `cycloneOwnerMatch` is `same` or `none` and `autoPatchOverride` is `false`, continue. If it is unset and the account flag is `false`, continue. If it is `true`, or it is unset while the account flag is `true`, refuse unless the human asked you to turn Cyclone off for this PR; only then run `mg stack set <stack-id> --auto-patch off` and re-read with `mg stack list --json`.
|
|
79
86
|
|
|
80
87
|
```bash
|
|
81
|
-
mg pr <owner/repo>#<n> --json --wait --after-sha <sha> --timeout <s>
|
|
88
|
+
mg pr <owner/repo>#<n> --json --wait --after-sha <sha> --after-pass <saved-pass> --timeout <s>
|
|
82
89
|
```
|
|
83
90
|
|
|
84
|
-
Parse stdout as one `mergestorm.pr_review/v1` object. Same fields, same stop rules.
|
|
91
|
+
Parse stdout as one `mergestorm.pr_review/v1` object. Same fields, same stop rules. `--after-pass` follows the same rule as `after_pass` above: only when the last envelope you acted on is on that same `head_sha`; omit it for a freshly pushed SHA. `--pass <n>` reads one exact attempt on the head.
|