mergestorm 0.3.9 → 0.3.13
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 +57 -13
- package/dist/api.d.ts +27 -38
- package/dist/api.js +81 -4
- package/dist/client/index.d.ts +5 -0
- package/dist/client/index.js +5 -0
- package/dist/commands/credits.js +33 -12
- package/dist/commands/login.js +28 -7
- package/dist/commands/registry.d.ts +1 -1
- package/dist/commands/registry.js +26 -19
- package/dist/commands/review-client.d.ts +93 -0
- package/dist/commands/review-client.js +289 -0
- package/dist/commands/review.d.ts +30 -6
- package/dist/commands/review.js +385 -146
- package/dist/commands/stack.d.ts +34 -2
- package/dist/commands/stack.js +252 -58
- package/dist/commands/status.d.ts +16 -2
- package/dist/commands/status.js +221 -21
- package/dist/errors.d.ts +18 -2
- package/dist/errors.js +22 -1
- package/dist/git-stack.d.ts +2 -0
- package/dist/git-stack.js +4 -0
- package/dist/git.d.ts +12 -1
- package/dist/git.js +52 -7
- package/dist/shell.d.ts +1 -1
- package/dist/shell.js +6 -3
- package/dist/stack-dto.d.ts +185 -0
- package/dist/stack-dto.js +7 -0
- package/dist/submit-pr-base.d.ts +31 -0
- package/dist/submit-pr-base.js +69 -0
- package/dist/ui/banner.js +7 -0
- package/dist/ui/box.d.ts +13 -9
- package/dist/ui/box.js +28 -44
- package/dist/ui/findings.d.ts +2 -0
- package/dist/ui/findings.js +2 -1
- package/dist/ui/job-envelope.d.ts +68 -0
- package/dist/ui/job-envelope.js +35 -0
- package/dist/ui/prompt.d.ts +91 -0
- package/dist/ui/prompt.js +241 -62
- package/dist/ui/usage.d.ts +25 -1
- package/dist/ui/usage.js +50 -1
- package/dist/ui/width.d.ts +26 -0
- package/dist/ui/width.js +262 -0
- package/package.json +5 -1
package/dist/commands/review.js
CHANGED
|
@@ -1,14 +1,194 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getMe } from "../api.js";
|
|
2
2
|
import { loadConfig } from "../config.js";
|
|
3
|
-
import { CommandError, DetachedError, isCommandErrorCode } from "../errors.js";
|
|
4
|
-
import { collectChangedFiles, git, parseGithubOriginRepo } from "../git.js";
|
|
3
|
+
import { CommandError, DetachedError, REVIEW_EXIT, isCommandErrorCode, rateLimitedMessage, } from "../errors.js";
|
|
5
4
|
import { discoverTrunk } from "../git-stack.js";
|
|
6
5
|
import { ansi } from "../ui/ansi.js";
|
|
7
6
|
import { renderFindings } from "../ui/findings.js";
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
*/
|
|
7
|
+
import { toReviewJobEnvelope, } from "../ui/job-envelope.js";
|
|
8
|
+
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";
|
|
9
|
+
export { REVIEW_POLL_MAX_TRANSIENT_RETRIES, isTransientReviewPollError, isTransientReviewPollStatus, };
|
|
10
|
+
/** Same public specialist allowlist as the API contract and migration 117. */
|
|
11
|
+
export const VORTEX_ROUTER_MODES = ["off", "standard", "max", "manual"];
|
|
12
|
+
/** Standard/Max fleet caps; Manual is capped at Max. */
|
|
13
|
+
export const VORTEX_ROUTER_CAPS = { standard: 3, max: 5 };
|
|
14
|
+
export const VORTEX_SPECIALIST_IDS = [
|
|
15
|
+
"security",
|
|
16
|
+
"performance",
|
|
17
|
+
"architecture",
|
|
18
|
+
"tests",
|
|
19
|
+
"data",
|
|
20
|
+
"api",
|
|
21
|
+
"frontend",
|
|
22
|
+
];
|
|
23
|
+
function isVortexRouterMode(value) {
|
|
24
|
+
return typeof value === "string" && VORTEX_ROUTER_MODES.includes(value);
|
|
25
|
+
}
|
|
26
|
+
function isVortexSpecialistId(value) {
|
|
27
|
+
return typeof value === "string" && VORTEX_SPECIALIST_IDS.includes(value);
|
|
28
|
+
}
|
|
29
|
+
function normalizeVortexSpecialistIds(value) {
|
|
30
|
+
if (!Array.isArray(value))
|
|
31
|
+
return [];
|
|
32
|
+
const selected = new Set(value.filter((id) => typeof id === "string" && VORTEX_SPECIALIST_IDS.includes(id)));
|
|
33
|
+
return VORTEX_SPECIALIST_IDS.filter((id) => selected.has(id));
|
|
34
|
+
}
|
|
35
|
+
const REVIEW_USAGE = "usage: mergestorm review [base] [head] [--json] [--wait|--no-wait] [--timeout seconds] [--router off|standard|max|manual] [--specialists id,id] [--context text] [--context-file path] [--idempotency-key k] [--webhook-url https://…] [--thread slug]";
|
|
36
|
+
function usageError(message = REVIEW_USAGE) {
|
|
37
|
+
return new CommandError(message, REVIEW_EXIT.usage, "usage");
|
|
38
|
+
}
|
|
39
|
+
function takeFlagValue(args, i, name) {
|
|
40
|
+
const a = args[i];
|
|
41
|
+
const prefix = `--${name}=`;
|
|
42
|
+
const value = a === `--${name}` ? args[i + 1] : a.slice(prefix.length);
|
|
43
|
+
if (value === undefined || (a === `--${name}` && value.startsWith("--"))) {
|
|
44
|
+
throw usageError(`--${name} requires a value`);
|
|
45
|
+
}
|
|
46
|
+
return { value, i: a === `--${name}` ? i + 1 : i };
|
|
47
|
+
}
|
|
48
|
+
/** Parse the human and machine-safe `review` flags. */
|
|
49
|
+
export function parseReviewArgs(args) {
|
|
50
|
+
const positional = [];
|
|
51
|
+
let router;
|
|
52
|
+
let specialists = [];
|
|
53
|
+
let format = "pretty";
|
|
54
|
+
let wait = true;
|
|
55
|
+
let waitFlag = null;
|
|
56
|
+
let timeoutMs = REVIEW_POLL_DEFAULT_TIMEOUT_MS;
|
|
57
|
+
let context;
|
|
58
|
+
const contextFiles = [];
|
|
59
|
+
let idempotencyKey;
|
|
60
|
+
let webhookUrl;
|
|
61
|
+
let thread;
|
|
62
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
63
|
+
const a = args[i];
|
|
64
|
+
if (a === "--json") {
|
|
65
|
+
format = "json";
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (a === "--wait" || a === "--no-wait") {
|
|
69
|
+
if (waitFlag && waitFlag !== a)
|
|
70
|
+
throw usageError("--wait and --no-wait cannot be combined");
|
|
71
|
+
waitFlag = a;
|
|
72
|
+
wait = a === "--wait";
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (a === "--timeout" || a.startsWith("--timeout=")) {
|
|
76
|
+
const value = a === "--timeout" ? args[++i] : a.slice("--timeout=".length);
|
|
77
|
+
const seconds = Number(value);
|
|
78
|
+
if (!value || !Number.isFinite(seconds) || seconds <= 0) {
|
|
79
|
+
throw usageError("--timeout must be a positive number of seconds");
|
|
80
|
+
}
|
|
81
|
+
timeoutMs = Math.round(seconds * 1_000);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (a === "--router" || a.startsWith("--router=")) {
|
|
85
|
+
const value = a === "--router" ? args[++i] : a.slice("--router=".length);
|
|
86
|
+
if (!isVortexRouterMode(value)) {
|
|
87
|
+
throw usageError();
|
|
88
|
+
}
|
|
89
|
+
router = value;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (a === "--specialists" || a.startsWith("--specialists=")) {
|
|
93
|
+
const value = a === "--specialists" ? args[++i] : a.slice("--specialists=".length);
|
|
94
|
+
if (!value?.trim()) {
|
|
95
|
+
throw usageError();
|
|
96
|
+
}
|
|
97
|
+
const raw = value.split(",").map((s) => s.trim()).filter(Boolean);
|
|
98
|
+
if (raw.length === 0)
|
|
99
|
+
throw usageError();
|
|
100
|
+
const unknown = raw.filter((id) => !isVortexSpecialistId(id));
|
|
101
|
+
if (unknown.length > 0) {
|
|
102
|
+
throw usageError(`Unknown specialist: ${[...new Set(unknown)].join(", ")}`);
|
|
103
|
+
}
|
|
104
|
+
specialists = normalizeVortexSpecialistIds(raw);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (a === "--context-file" || a.startsWith("--context-file=")) {
|
|
108
|
+
const taken = takeFlagValue(args, i, "context-file");
|
|
109
|
+
i = taken.i;
|
|
110
|
+
if (!taken.value.trim())
|
|
111
|
+
throw usageError("--context-file requires a path");
|
|
112
|
+
contextFiles.push(taken.value);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (a === "--context" || a.startsWith("--context=")) {
|
|
116
|
+
const taken = takeFlagValue(args, i, "context");
|
|
117
|
+
i = taken.i;
|
|
118
|
+
context = taken.value;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (a === "--idempotency-key" || a.startsWith("--idempotency-key=")) {
|
|
122
|
+
const taken = takeFlagValue(args, i, "idempotency-key");
|
|
123
|
+
i = taken.i;
|
|
124
|
+
const key = taken.value.trim();
|
|
125
|
+
if (!key)
|
|
126
|
+
throw usageError("--idempotency-key requires a value");
|
|
127
|
+
if (key.length > MAX_IDEMPOTENCY_KEY_CHARS) {
|
|
128
|
+
throw usageError(`--idempotency-key is ${key.length} chars; max is ${MAX_IDEMPOTENCY_KEY_CHARS}`);
|
|
129
|
+
}
|
|
130
|
+
idempotencyKey = key;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (a === "--webhook-url" || a.startsWith("--webhook-url=")) {
|
|
134
|
+
const taken = takeFlagValue(args, i, "webhook-url");
|
|
135
|
+
i = taken.i;
|
|
136
|
+
let parsedUrl;
|
|
137
|
+
try {
|
|
138
|
+
parsedUrl = new URL(taken.value);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
throw usageError("--webhook-url must be an https URL");
|
|
142
|
+
}
|
|
143
|
+
if (parsedUrl.protocol !== "https:") {
|
|
144
|
+
throw usageError("--webhook-url must be an https URL");
|
|
145
|
+
}
|
|
146
|
+
webhookUrl = parsedUrl.toString();
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (a === "--thread" || a.startsWith("--thread=")) {
|
|
150
|
+
const taken = takeFlagValue(args, i, "thread");
|
|
151
|
+
i = taken.i;
|
|
152
|
+
const slug = taken.value.trim();
|
|
153
|
+
if (!REVIEW_THREAD_SLUG_RE.test(slug)) {
|
|
154
|
+
throw usageError("--thread must be 1–120 chars [A-Za-z0-9._/-]");
|
|
155
|
+
}
|
|
156
|
+
thread = slug;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (a.startsWith("-")) {
|
|
160
|
+
throw usageError(`unknown flag: ${a}`);
|
|
161
|
+
}
|
|
162
|
+
positional.push(a);
|
|
163
|
+
}
|
|
164
|
+
if (positional.length > 2)
|
|
165
|
+
throw usageError();
|
|
166
|
+
if (specialists.length > 0 && router == null)
|
|
167
|
+
router = "manual";
|
|
168
|
+
if (router === "manual" && specialists.length === 0) {
|
|
169
|
+
throw usageError("usage: mergestorm review [base] [head] --router manual --specialists id,id");
|
|
170
|
+
}
|
|
171
|
+
if (router === "manual" && specialists.length > VORTEX_ROUTER_CAPS.max) {
|
|
172
|
+
throw usageError("usage: mergestorm review [base] [head] --router manual --specialists id,id (manual runs at most 5 specialists)");
|
|
173
|
+
}
|
|
174
|
+
if (router === "off" && specialists.length > 0) {
|
|
175
|
+
throw usageError("--router off cannot be combined with --specialists");
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
base: positional[0],
|
|
179
|
+
head: positional[1],
|
|
180
|
+
router,
|
|
181
|
+
specialists,
|
|
182
|
+
format,
|
|
183
|
+
wait,
|
|
184
|
+
timeoutMs,
|
|
185
|
+
context,
|
|
186
|
+
contextFiles,
|
|
187
|
+
idempotencyKey,
|
|
188
|
+
webhookUrl,
|
|
189
|
+
thread,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
12
192
|
export function resolveReviewBase(explicit, cwd = process.cwd()) {
|
|
13
193
|
if (explicit)
|
|
14
194
|
return explicit;
|
|
@@ -16,25 +196,9 @@ export function resolveReviewBase(explicit, cwd = process.cwd()) {
|
|
|
16
196
|
return discoverTrunk(cwd);
|
|
17
197
|
}
|
|
18
198
|
catch {
|
|
19
|
-
throw new CommandError("Could not discover trunk branch. Pass an explicit base: mergestorm review <base> [head]");
|
|
199
|
+
throw new CommandError("Could not discover trunk branch. Pass an explicit base: mergestorm review <base> [head]", REVIEW_EXIT.usage, "usage");
|
|
20
200
|
}
|
|
21
201
|
}
|
|
22
|
-
function sleep(ms, signal) {
|
|
23
|
-
return new Promise((resolve, reject) => {
|
|
24
|
-
if (signal?.aborted) {
|
|
25
|
-
reject(Object.assign(new Error("Aborted"), { name: "AbortError" }));
|
|
26
|
-
return;
|
|
27
|
-
}
|
|
28
|
-
const t = setTimeout(resolve, ms);
|
|
29
|
-
const onAbort = () => {
|
|
30
|
-
clearTimeout(t);
|
|
31
|
-
reject(Object.assign(new Error("Aborted"), { name: "AbortError" }));
|
|
32
|
-
};
|
|
33
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
34
|
-
});
|
|
35
|
-
}
|
|
36
|
-
/** Max consecutive transient poll failures before surfacing an error. */
|
|
37
|
-
export const REVIEW_POLL_MAX_TRANSIENT_RETRIES = 3;
|
|
38
202
|
export function reviewStatusHint(jobId) {
|
|
39
203
|
return `Check: mergestorm status ${jobId}`;
|
|
40
204
|
}
|
|
@@ -45,149 +209,224 @@ export function withReviewJobRecovery(message, jobId) {
|
|
|
45
209
|
return message;
|
|
46
210
|
return `${message.replace(/\s+$/, "")}\n${hint}`;
|
|
47
211
|
}
|
|
48
|
-
/** HTTP statuses worth retrying during review poll (not 401/402/404). */
|
|
49
|
-
export function isTransientReviewPollStatus(status) {
|
|
50
|
-
return status === 408 || status === 429 || status >= 500;
|
|
51
|
-
}
|
|
52
|
-
/** Network / request-timeout errors worth retrying during review poll. */
|
|
53
|
-
export function isTransientReviewPollError(err) {
|
|
54
|
-
if (!(err instanceof Error))
|
|
55
|
-
return false;
|
|
56
|
-
if (err.name === "AbortError")
|
|
57
|
-
return false;
|
|
58
|
-
if (err instanceof CommandError) {
|
|
59
|
-
return err.code === "api_timeout";
|
|
60
|
-
}
|
|
61
|
-
if (err instanceof TypeError && /fetch|network|connection/i.test(err.message))
|
|
62
|
-
return true;
|
|
63
|
-
return /fetch failed|ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|socket hang up/i.test(err.message);
|
|
64
|
-
}
|
|
65
212
|
function asPollCommandError(err, jobId) {
|
|
66
213
|
if (err instanceof CommandError) {
|
|
67
|
-
return new CommandError(withReviewJobRecovery(err.message, jobId), err.exitCode, err.code
|
|
214
|
+
return new CommandError(withReviewJobRecovery(err.message, jobId), err.exitCode, err.code, err.retryAfterSeconds !== undefined
|
|
215
|
+
? { retryAfterSeconds: err.retryAfterSeconds }
|
|
216
|
+
: undefined);
|
|
68
217
|
}
|
|
69
218
|
const detail = err instanceof Error ? err.message : String(err);
|
|
70
219
|
return new CommandError(withReviewJobRecovery(`Poll failed: ${detail}`, jobId));
|
|
71
220
|
}
|
|
72
221
|
export async function cmdReview(args, opts = {}) {
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
const
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
...(originRepo ? { repo: `${originRepo.owner}/${originRepo.repo}` } : {}),
|
|
101
|
-
base_label: base,
|
|
102
|
-
head_label: head,
|
|
103
|
-
diff,
|
|
104
|
-
files,
|
|
105
|
-
},
|
|
106
|
-
signal: opts.signal,
|
|
107
|
-
});
|
|
108
|
-
if (status === 402) {
|
|
109
|
-
throw new CommandError("Monthly review limit reached. Upgrade at https://mergestorm.ai/billing");
|
|
110
|
-
}
|
|
111
|
-
if (status !== 202 && status !== 200) {
|
|
112
|
-
throw new CommandError(`Failed: ${JSON.stringify(body, null, 2)}`);
|
|
113
|
-
}
|
|
114
|
-
const job = body;
|
|
115
|
-
const waitHint = opts.interactive
|
|
116
|
-
? ` Waiting… (${ansi.dim("ctrl+c to detach — job keeps running")})`
|
|
117
|
-
: " Waiting…";
|
|
118
|
-
console.log(`Job ${job.job_id} (${job.status}).${waitHint}`);
|
|
119
|
-
let transientFailures = 0;
|
|
222
|
+
const parsed = parseReviewArgs(args);
|
|
223
|
+
const base = resolveReviewBase(parsed.base);
|
|
224
|
+
const head = parsed.head || "HEAD";
|
|
225
|
+
const json = parsed.format === "json";
|
|
226
|
+
const interactive = Boolean(opts.interactive && !json);
|
|
227
|
+
const fallback = {
|
|
228
|
+
baseLabel: base,
|
|
229
|
+
headLabel: head,
|
|
230
|
+
routerMode: parsed.router ?? null,
|
|
231
|
+
specialistsRequested: parsed.specialists,
|
|
232
|
+
};
|
|
233
|
+
let jsonPrinted = false;
|
|
234
|
+
let webhookSecret = null;
|
|
235
|
+
const printJson = (row, extra = {}) => {
|
|
236
|
+
const rowWithSecret = {
|
|
237
|
+
...(row ?? {}),
|
|
238
|
+
...(webhookSecret ? { webhook_secret: webhookSecret } : {}),
|
|
239
|
+
};
|
|
240
|
+
console.log(JSON.stringify(toReviewJobEnvelope(rowWithSecret, { ...fallback, ...extra })));
|
|
241
|
+
jsonPrinted = true;
|
|
242
|
+
};
|
|
243
|
+
const progress = (message) => {
|
|
244
|
+
if (json)
|
|
245
|
+
console.error(message);
|
|
246
|
+
else
|
|
247
|
+
console.log(message);
|
|
248
|
+
};
|
|
120
249
|
try {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
250
|
+
const cfg = await loadConfig();
|
|
251
|
+
const meBefore = interactive ? await getMe(cfg) : null;
|
|
252
|
+
progress(`Collecting diff ${base}...${head} …`);
|
|
253
|
+
const input = await collectReviewInput(base, head);
|
|
254
|
+
if (!input) {
|
|
255
|
+
if (json)
|
|
256
|
+
printJson(null, { status: "no_changes" });
|
|
257
|
+
else
|
|
258
|
+
console.log("No changes to review.");
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (parsed.thread)
|
|
262
|
+
input.thread = parsed.thread;
|
|
263
|
+
fallback.threadSlug = input.thread;
|
|
264
|
+
const extras = await loadReviewContext({
|
|
265
|
+
context: parsed.context,
|
|
266
|
+
contextFiles: parsed.contextFiles,
|
|
267
|
+
});
|
|
268
|
+
progress(`Submitting review (${Buffer.byteLength(input.diff, "utf8")} byte diff, ${input.files.length} file(s))…`);
|
|
269
|
+
if (parsed.router) {
|
|
270
|
+
progress(`Fleet router=${parsed.router}${parsed.specialists.length ? ` specialists=${parsed.specialists.join(",")}` : ""}`);
|
|
271
|
+
}
|
|
272
|
+
let submitted;
|
|
273
|
+
try {
|
|
274
|
+
submitted = await submitReview(cfg, input, {
|
|
275
|
+
routerMode: parsed.router,
|
|
276
|
+
specialists: parsed.specialists,
|
|
277
|
+
context: extras.context,
|
|
278
|
+
contextFiles: extras.contextFiles,
|
|
279
|
+
idempotencyKey: parsed.idempotencyKey,
|
|
280
|
+
webhookUrl: parsed.webhookUrl,
|
|
281
|
+
signal: opts.signal,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
catch (err) {
|
|
285
|
+
if (!isTransientReviewPollError(err))
|
|
286
|
+
throw err;
|
|
287
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
288
|
+
if (json)
|
|
289
|
+
printJson(null, { status: "in_progress", error: message });
|
|
290
|
+
throw new CommandError(message, REVIEW_EXIT.timeout, "review_timeout");
|
|
291
|
+
}
|
|
292
|
+
const jobId = submitted.row.job_id ?? submitted.row.id ?? null;
|
|
293
|
+
fallback.jobId = jobId;
|
|
294
|
+
webhookSecret = submitted.row.webhook_secret ?? null;
|
|
295
|
+
if (submitted.status === 402) {
|
|
296
|
+
const message = "Review limit reached for this period. Upgrade at https://mergestorm.ai/billing";
|
|
297
|
+
if (json)
|
|
298
|
+
printJson(submitted.row, { status: "quota_exceeded", error: message });
|
|
299
|
+
throw new CommandError(message, REVIEW_EXIT.quota, "review_quota");
|
|
300
|
+
}
|
|
301
|
+
if (submitted.status === 429) {
|
|
302
|
+
const retryAfterSeconds = submitted.retryAfterSeconds;
|
|
303
|
+
const message = rateLimitedMessage(retryAfterSeconds);
|
|
304
|
+
if (json) {
|
|
305
|
+
printJson(submitted.row, {
|
|
306
|
+
status: "rate_limited",
|
|
307
|
+
error: message,
|
|
308
|
+
retryAfterSeconds,
|
|
309
|
+
});
|
|
126
310
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
}
|
|
141
|
-
|
|
311
|
+
throw new CommandError(message, REVIEW_EXIT.rate_limited, "rate_limited", {
|
|
312
|
+
retryAfterSeconds,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
if (submitted.status !== 202 && submitted.status !== 200) {
|
|
316
|
+
const message = `Failed: ${JSON.stringify(submitted.row, null, 2)}`;
|
|
317
|
+
if (json)
|
|
318
|
+
printJson(submitted.row, { status: "failed", error: message });
|
|
319
|
+
throw new CommandError(message, REVIEW_EXIT.failed, "review_failed");
|
|
320
|
+
}
|
|
321
|
+
if (!jobId) {
|
|
322
|
+
const message = "Review API returned no job_id";
|
|
323
|
+
if (json)
|
|
324
|
+
printJson(submitted.row, { status: "failed", error: message });
|
|
325
|
+
throw new CommandError(message, REVIEW_EXIT.failed, "review_failed");
|
|
326
|
+
}
|
|
327
|
+
if (!parsed.wait) {
|
|
328
|
+
if (json)
|
|
329
|
+
printJson(submitted.row, { jobId });
|
|
330
|
+
else
|
|
331
|
+
console.log(`Job ${jobId} (${submitted.row.status ?? "queued"}).`);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (!json) {
|
|
335
|
+
const waitHint = interactive
|
|
336
|
+
? ` Waiting… (${ansi.dim("ctrl+c to detach — job keeps running")})`
|
|
337
|
+
: " Waiting…";
|
|
338
|
+
console.log(`Job ${jobId} (${submitted.row.status ?? "queued"}).${waitHint}`);
|
|
339
|
+
}
|
|
340
|
+
let row;
|
|
341
|
+
try {
|
|
342
|
+
row = await pollReview(cfg, jobId, {
|
|
343
|
+
timeoutMs: parsed.timeoutMs,
|
|
344
|
+
intervalMs: opts.pollIntervalMs,
|
|
345
|
+
signal: opts.signal,
|
|
346
|
+
propagateTimeout: interactive,
|
|
347
|
+
onTick(kind) {
|
|
348
|
+
if (json)
|
|
349
|
+
console.error(kind === "retry" ? "?" : ".");
|
|
350
|
+
else
|
|
351
|
+
process.stdout.write(kind === "retry" ? "?" : ".");
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
catch (err) {
|
|
356
|
+
if (opts.interactive && err instanceof Error && err.name === "AbortError") {
|
|
357
|
+
throw new DetachedError(jobId);
|
|
142
358
|
}
|
|
143
|
-
if (
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
359
|
+
if (interactive && isCommandErrorCode(err, "api_timeout")) {
|
|
360
|
+
throw new DetachedError(jobId);
|
|
361
|
+
}
|
|
362
|
+
if (err instanceof ReviewPollTimeoutError) {
|
|
363
|
+
const message = withReviewJobRecovery("Timed out waiting for review.", jobId);
|
|
364
|
+
if (json) {
|
|
365
|
+
printJson(err.lastRow, {
|
|
366
|
+
jobId,
|
|
367
|
+
status: err.lastRow.status ?? "in_progress",
|
|
368
|
+
error: "Timed out waiting for review.",
|
|
369
|
+
});
|
|
150
370
|
}
|
|
151
|
-
throw new CommandError(
|
|
371
|
+
throw new CommandError(message, REVIEW_EXIT.timeout, "review_timeout");
|
|
152
372
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
373
|
+
if (isCommandErrorCode(err, "rate_limited")) {
|
|
374
|
+
if (json) {
|
|
375
|
+
printJson(null, {
|
|
376
|
+
status: "rate_limited",
|
|
377
|
+
error: err.message,
|
|
378
|
+
retryAfterSeconds: err.retryAfterSeconds,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
throw asPollCommandError(err, jobId);
|
|
158
382
|
}
|
|
383
|
+
throw asPollCommandError(err, jobId);
|
|
384
|
+
}
|
|
385
|
+
if (!json)
|
|
159
386
|
console.log("");
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
387
|
+
if (row.status === "failed" || row.status === "quota_exceeded") {
|
|
388
|
+
const message = withReviewJobRecovery(`Review ${row.status}: ${row.error ?? ""}`, jobId);
|
|
389
|
+
if (json)
|
|
390
|
+
printJson(row, { jobId });
|
|
391
|
+
throw new CommandError(message, row.status === "quota_exceeded" ? REVIEW_EXIT.quota : REVIEW_EXIT.failed, row.status === "quota_exceeded" ? "review_quota" : "review_failed");
|
|
392
|
+
}
|
|
393
|
+
if (json) {
|
|
394
|
+
printJson(row, { jobId });
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
console.log(`\nVerdict: ${ansi.bold(row.verdict ?? "comment")}`);
|
|
398
|
+
const ran = row.specialists_run ?? row.findings?.specialists_run ?? [];
|
|
399
|
+
if (ran.length > 0)
|
|
400
|
+
console.log(ansi.dim(`Specialists: ${ran.join(", ")}`));
|
|
401
|
+
console.log(row.summary ?? "");
|
|
402
|
+
renderFindings(row.findings, {
|
|
403
|
+
emptyMessage: true,
|
|
404
|
+
});
|
|
405
|
+
if (interactive && meBefore) {
|
|
406
|
+
const meAfter = await getMe(cfg);
|
|
407
|
+
if (meAfter) {
|
|
408
|
+
const beforeLeft = meBefore.usage.standard.remaining ?? 0;
|
|
409
|
+
const afterLeft = meAfter.usage.standard.remaining ?? 0;
|
|
410
|
+
const used = Math.max(0, beforeLeft - afterLeft);
|
|
411
|
+
if (used > 0) {
|
|
412
|
+
console.log(ansi.dim(`\n${used} credit${used === 1 ? "" : "s"} used · ${afterLeft} remaining`));
|
|
175
413
|
}
|
|
176
414
|
}
|
|
177
|
-
return;
|
|
178
415
|
}
|
|
179
416
|
}
|
|
180
417
|
catch (err) {
|
|
181
|
-
if (
|
|
182
|
-
throw
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
418
|
+
if (err instanceof DetachedError)
|
|
419
|
+
throw err;
|
|
420
|
+
if (json &&
|
|
421
|
+
!jsonPrinted &&
|
|
422
|
+
!(err instanceof Error && err.name === "AbortError")) {
|
|
423
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
424
|
+
printJson(null, { status: "failed", error: message });
|
|
186
425
|
}
|
|
187
|
-
if (err instanceof CommandError
|
|
188
|
-
|
|
426
|
+
if (err instanceof CommandError &&
|
|
427
|
+
(err.code === "auth_invalid" || err.code === "missing_api_key")) {
|
|
428
|
+
throw new CommandError(err.message, REVIEW_EXIT.auth, err.code);
|
|
189
429
|
}
|
|
190
430
|
throw err;
|
|
191
431
|
}
|
|
192
|
-
throw new CommandError(withReviewJobRecovery("Timed out waiting for review.", job.job_id));
|
|
193
432
|
}
|
package/dist/commands/stack.d.ts
CHANGED
|
@@ -1,10 +1,26 @@
|
|
|
1
|
-
import { adoptStack } from "../api.js";
|
|
1
|
+
import { adoptStack, ensureUpperPark, listStacks, type StackDto } 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, pushBranch, tipCommitMessage, tipCommitSubject } from "../git-stack.js";
|
|
5
|
+
import { branchExists, commitsAheadOf, fetchRemoteBranch, pushBranch, tipCommitMessage, tipCommitSubject } from "../git-stack.js";
|
|
6
6
|
import { buildPrBodyFromCommit } from "../pr-body.js";
|
|
7
7
|
import { loadStackMeta, saveStackMeta } from "../stack-meta.js";
|
|
8
|
+
/** A registered stack layer that would become the parent of a new layer. */
|
|
9
|
+
export type RegisteredParentHit = {
|
|
10
|
+
stackId: string;
|
|
11
|
+
owner: string;
|
|
12
|
+
repo: string;
|
|
13
|
+
branch: string;
|
|
14
|
+
prNumber: number;
|
|
15
|
+
position: number;
|
|
16
|
+
};
|
|
17
|
+
/** Find a registered stack layer whose branch matches `parentBranch`. */
|
|
18
|
+
export declare function findRegisteredParent(stacks: StackDto[], parentBranch: string, owner?: string, repo?: string): RegisteredParentHit | null;
|
|
19
|
+
/**
|
|
20
|
+
* Refuse silently growing an already-registered stack.
|
|
21
|
+
* Local unsubmitted parents are fine; only API-listed layers trip this.
|
|
22
|
+
*/
|
|
23
|
+
export declare function assertMayParentOntoRegistered(parentBranch: string, stacks: StackDto[], extend: boolean, verb: "create" | "submit", owner?: string, repo?: string): RegisteredParentHit | null;
|
|
8
24
|
export declare function requireStackId(raw: string | undefined, usage: string): string;
|
|
9
25
|
/** Parse `owner/repo#12` or `owner/repo 12`. */
|
|
10
26
|
export declare function parseAdoptTarget(args: string[]): {
|
|
@@ -17,9 +33,22 @@ export declare function parseStackCreateArgs(args: string[]): {
|
|
|
17
33
|
name?: string;
|
|
18
34
|
onto?: string;
|
|
19
35
|
trunk?: string;
|
|
36
|
+
extend: boolean;
|
|
37
|
+
asJson: boolean;
|
|
38
|
+
};
|
|
39
|
+
/** Parse `stack submit` argv. Exported for tests. */
|
|
40
|
+
export declare function parseStackSubmitArgs(args: string[]): {
|
|
41
|
+
extend: boolean;
|
|
20
42
|
asJson: boolean;
|
|
21
43
|
};
|
|
22
44
|
export declare function parseStackResetArgs(args: string[]): void;
|
|
45
|
+
/** Injectable seams for unit tests. Production callers omit deps. */
|
|
46
|
+
export type StackCreateDeps = {
|
|
47
|
+
cwd?: string;
|
|
48
|
+
listStacks?: typeof listStacks;
|
|
49
|
+
loadConfig?: typeof loadConfig;
|
|
50
|
+
parseGithubOriginRepo?: typeof parseGithubOriginRepo;
|
|
51
|
+
};
|
|
23
52
|
/** Injectable seams for unit tests (STRUCT-04). Production callers omit deps. */
|
|
24
53
|
export type StackSubmitDeps = {
|
|
25
54
|
cwd?: string;
|
|
@@ -29,6 +58,7 @@ export type StackSubmitDeps = {
|
|
|
29
58
|
parseGithubOriginRepo?: typeof parseGithubOriginRepo;
|
|
30
59
|
requireGh?: (cwd?: string) => void;
|
|
31
60
|
branchExists?: typeof branchExists;
|
|
61
|
+
fetchRemoteBranch?: typeof fetchRemoteBranch;
|
|
32
62
|
pushBranch?: typeof pushBranch;
|
|
33
63
|
findOpenPrNumber?: typeof findOpenPrNumber;
|
|
34
64
|
commitsAheadOf?: typeof commitsAheadOf;
|
|
@@ -37,7 +67,9 @@ export type StackSubmitDeps = {
|
|
|
37
67
|
buildPrBodyFromCommit?: typeof buildPrBodyFromCommit;
|
|
38
68
|
createPr?: typeof createPr;
|
|
39
69
|
loadConfig?: typeof loadConfig;
|
|
70
|
+
listStacks?: typeof listStacks;
|
|
40
71
|
adoptStack?: typeof adoptStack;
|
|
72
|
+
ensureUpperPark?: typeof ensureUpperPark;
|
|
41
73
|
};
|
|
42
74
|
export declare function cmdStackSubmit(args: string[], deps?: StackSubmitDeps): Promise<void>;
|
|
43
75
|
export declare function cmdStack(args: string[]): Promise<void>;
|