@tokenoftrust/cli 1.4.0-rc.21 → 1.4.0-rc.23
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/package.json +1 -1
- package/src/candidate-state.mjs +2 -2
- package/src/commands/accept.mjs +17 -1
- package/src/commands/pr.mjs +2 -2
- package/src/commands/preview-doctor.mjs +225 -0
- package/src/commands/preview.mjs +8 -0
- package/src/commands/ship.mjs +19 -1
- package/src/commands/submit.mjs +297 -33
- package/src/merge-doctor-report.mjs +208 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "1.4.0-rc.
|
|
3
|
+
"version": "1.4.0-rc.23",
|
|
4
4
|
"description": "Token of Trust developer CLI — clone a tenant store, run it locally with save→reload, and submit it for preview. Installs the `tot` command.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Token of Trust",
|
package/src/candidate-state.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* nothing here (backward-compatible with the stateless original). This file only
|
|
8
8
|
* records a DIVERGENCE from that stable default:
|
|
9
9
|
*
|
|
10
|
-
* - `tot submit --
|
|
10
|
+
* - `tot submit --fork-candidate` forks a fresh candidate and remembers it here, so the
|
|
11
11
|
* NEXT plain `tot submit` keeps updating the NEW PR (like pushing more commits
|
|
12
12
|
* to a `gh pr` branch), not the old one; and
|
|
13
13
|
* - a terminal-roll (the active candidate was merged/closed) records the fresh
|
|
@@ -123,7 +123,7 @@ export function clearActiveChangeId(filePath, { mcpUrl, repo, branch }) {
|
|
|
123
123
|
/**
|
|
124
124
|
* A fresh candidate handle forked from a stable base — `<baseId>-<suffix>`, still
|
|
125
125
|
* matching candidate_open's `[a-z0-9._-]` handle grammar. The suffix defaults to
|
|
126
|
-
* 6 random hex chars (so two `--
|
|
126
|
+
* 6 random hex chars (so two `--fork-candidate` runs never collide); tests inject a fixed
|
|
127
127
|
* suffix. Pure given `suffix`.
|
|
128
128
|
*/
|
|
129
129
|
export function mintFreshChangeId(baseId, suffix = randomBytes(3).toString("hex")) {
|
package/src/commands/accept.mjs
CHANGED
|
@@ -48,6 +48,10 @@ import { establishSession, AuthUnavailableError } from "../auth.mjs";
|
|
|
48
48
|
// No-operator-secret path: mint a viewer session from the developer's OWN `tot`
|
|
49
49
|
// login and integrate as themselves (server gates on their live ship-on-behalf grant).
|
|
50
50
|
import { resolveViewerTransport } from "../viewer-session.mjs";
|
|
51
|
+
// C5 — the doctor PUSH: on a failed accept/integrate, auto-append the COMPACT
|
|
52
|
+
// "here's what's blocking you" summary over the SAME transport we just used, so a
|
|
53
|
+
// developer doesn't have to remember to run `tot preview doctor` themselves.
|
|
54
|
+
import { autoSurfaceDoctor } from "../merge-doctor-report.mjs";
|
|
51
55
|
|
|
52
56
|
const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
|
|
53
57
|
const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
|
|
@@ -531,6 +535,15 @@ export async function runIntegrate(
|
|
|
531
535
|
authHeaders = viewer.authHeaders;
|
|
532
536
|
}
|
|
533
537
|
|
|
538
|
+
// C5 — auto-surface the merge-doctor on a failed accept. Best-effort: fetches the
|
|
539
|
+
// hosted doctor over the SAME transport we just resolved and prints the COMPACT
|
|
540
|
+
// summary; never throws and never touches the exit code (it's called AFTER the
|
|
541
|
+
// outcome is decided, purely to append diagnostic context to where the failure
|
|
542
|
+
// already surfaced). A clean/unreachable doctor prints nothing.
|
|
543
|
+
const surfaceDoctor = async () => {
|
|
544
|
+
for (const line of await autoSurfaceDoctor({ base, authHeaders, fetchImpl })) console.error(line);
|
|
545
|
+
};
|
|
546
|
+
|
|
534
547
|
// 2.5. MERGEABLE PREFLIGHT — read the candidate's forge verdict from b07's queue
|
|
535
548
|
// (`GET /api/changes`, SAME transport) so a doomed accept never round-trips to
|
|
536
549
|
// the forge. Best-effort: on any listing gap it's `known:false` → proceed as
|
|
@@ -625,10 +638,13 @@ export async function runIntegrate(
|
|
|
625
638
|
for (const line of notMergeableOfferLines(tenant, { pr: curPr, changeId: curChangeId })) {
|
|
626
639
|
console.log(line);
|
|
627
640
|
}
|
|
641
|
+
await surfaceDoctor();
|
|
628
642
|
return 1;
|
|
629
643
|
}
|
|
630
644
|
|
|
631
|
-
|
|
645
|
+
const code = reportIntegrated(result, { tenant, label: /** @type {string} */ (label) });
|
|
646
|
+
if (code !== 0) await surfaceDoctor();
|
|
647
|
+
return code;
|
|
632
648
|
}
|
|
633
649
|
|
|
634
650
|
/** The mergeable PREFLIGHT read (best-effort). See readCandidateVerdict. */
|
package/src/commands/pr.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* tot pr close <N|id> close (reject) a candidate without merging
|
|
8
8
|
*
|
|
9
9
|
* A candidate PR is the reviewable unit `tot submit` creates. By default a
|
|
10
|
-
* re-submit UPDATES your open candidate; `tot submit --
|
|
10
|
+
* re-submit UPDATES your open candidate; `tot submit --fork-candidate` forks another. This
|
|
11
11
|
* command fills the gap the raw submit loop left — a first-party way to list your
|
|
12
12
|
* open candidates and to close one (candidate close was otherwise gated to
|
|
13
13
|
* version-control apps only).
|
|
@@ -71,7 +71,7 @@ const USAGE = `tot pr — see and manage candidate PRs
|
|
|
71
71
|
|
|
72
72
|
DEVELOPER (default) — run inside your OWN store checkout: lists/manages the
|
|
73
73
|
candidates \`tot submit\` opens. A re-submit updates your open one by default;
|
|
74
|
-
\`tot submit --
|
|
74
|
+
\`tot submit --fork-candidate\` forks another.
|
|
75
75
|
|
|
76
76
|
OPERATOR — \`tot pr list --tenant <appDomain>\` lists ANY tenant's open candidate
|
|
77
77
|
queue WITHOUT a checkout (the read-only companion to \`tot ship --pr <N> --tenant\`).
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tot preview doctor --tenant <t>` — the merge self-serve DIAGNOSIS in the terminal.
|
|
3
|
+
*
|
|
4
|
+
* Calls the hosted merge-doctor seam `GET /<tenant>/api/preview/merge-doctor` (unit
|
|
5
|
+
* A2) — which composes the SAME two server-side reads the admin Publish tab builds
|
|
6
|
+
* (`GET /api/changes` + `GET /api/changes/branches`) through the PURE `analyzeMerge`
|
|
7
|
+
* taxonomy — and prints its `{ scopeKnown, verdict, findings, counts }` as the compact,
|
|
8
|
+
* ordered (blocker → warn → info) report, each finding carrying who-owns-it and its
|
|
9
|
+
* remedy/action. It composes NO per-PR forge calls of its own: one GET, one answer.
|
|
10
|
+
*
|
|
11
|
+
* READ-ONLY — it mutates nothing, so there is NO plan/confirm gate (unlike `tot
|
|
12
|
+
* accept` / `tot preview build`). It just answers "what's blocking my merges, and
|
|
13
|
+
* what do I do about it?" in seconds.
|
|
14
|
+
*
|
|
15
|
+
* AUTH — the endpoint is session-gated exactly like `GET /api/changes`, so this
|
|
16
|
+
* reaches it by EITHER of the two transports `tot accept` already speaks:
|
|
17
|
+
* - OPERATOR SECRET (operators / headless CI): Bearer + `X-Tot-Owner` on the
|
|
18
|
+
* generic storefront origin (from PREVIEW_RECONCILE_SECRET / GRANTS_ADMIN_SECRET
|
|
19
|
+
* / TOT_OPERATOR_SECRET, or `--secret`).
|
|
20
|
+
* - VIEWER SESSION (an invited developer with no secret): mint a `tot_session`
|
|
21
|
+
* from their own `tot login` on the tenant's own host (`resolveViewerTransport`).
|
|
22
|
+
* A caller who cannot see the accept queue cannot get a diagnosis of it — an auth
|
|
23
|
+
* refusal (401/403) is relayed as a clean house-style failure, not a wrapped verdict.
|
|
24
|
+
*
|
|
25
|
+
* EXIT CODE — mirrors the `scripts/tenant/gitea-merge-doctor.mjs` driver: a report
|
|
26
|
+
* with any blocker exits 1 (so a CI gate fails on a blocked merge), otherwise 0.
|
|
27
|
+
*
|
|
28
|
+
* The rendering helpers are the SHARED port in `../merge-doctor-report.mjs` (a
|
|
29
|
+
* faithful PORT of the analyzer's own `formatReport`/`attentionBanner`/
|
|
30
|
+
* `supportContext` — mergeDoctor.ts §render / the gitea-merge-doctor.mjs mirror);
|
|
31
|
+
* this package is the dependency-free published `@tokenoftrust/cli`, so it cannot
|
|
32
|
+
* import the app/scripts source, and the port is kept in sync with that taxonomy by
|
|
33
|
+
* hand. They live in their own module so `tot accept` / `tot ship` can reuse the same
|
|
34
|
+
* fetch + compact render (C5) without a cycle back through this command's transports.
|
|
35
|
+
*
|
|
36
|
+
* Dependency-free (global fetch + the shared viewer/operator transports).
|
|
37
|
+
*/
|
|
38
|
+
import { fail } from "../errors.mjs";
|
|
39
|
+
import { resolveOperatorSecret } from "./ship.mjs";
|
|
40
|
+
import { resolveViewerTransport } from "../viewer-session.mjs";
|
|
41
|
+
import {
|
|
42
|
+
DOCTOR_PATH,
|
|
43
|
+
normalizeAnalysis,
|
|
44
|
+
attentionBanner,
|
|
45
|
+
supportContext,
|
|
46
|
+
formatReport,
|
|
47
|
+
} from "../merge-doctor-report.mjs";
|
|
48
|
+
|
|
49
|
+
const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
|
|
50
|
+
|
|
51
|
+
const USAGE = `tot preview doctor — diagnose what's blocking this tenant's merges
|
|
52
|
+
|
|
53
|
+
tot preview doctor diagnose the current checkout's tenant
|
|
54
|
+
tot preview doctor --tenant <t> diagnose <t> (works without a checkout)
|
|
55
|
+
tot preview doctor --json emit the raw { scopeKnown, verdict, findings, counts }
|
|
56
|
+
|
|
57
|
+
Calls the hosted merge-doctor (GET /api/preview/merge-doctor), which composes the
|
|
58
|
+
SAME reads the admin Publish tab builds and runs them through the shared analyzer,
|
|
59
|
+
and prints the ordered report: blockers first, then warnings, then info — each
|
|
60
|
+
finding names who resolves it (you / on-us / housekeeping) and the exact remedy.
|
|
61
|
+
Read-only: it mutates nothing and needs no confirmation.
|
|
62
|
+
|
|
63
|
+
Exit code: 1 if the report has any blocker (so it can gate CI), else 0.
|
|
64
|
+
|
|
65
|
+
Options:
|
|
66
|
+
--tenant <appDomain> Target tenant (e.g. tokenoftrust.com). Defaults to the
|
|
67
|
+
current checkout's tenant when run inside one.
|
|
68
|
+
--url <origin> Storefront origin for the operator-secret path
|
|
69
|
+
(default: env TOT_STOREFRONT_URL or ${DEFAULT_STOREFRONT_URL}).
|
|
70
|
+
--secret <s> Operator secret (prefer the env vars below).
|
|
71
|
+
--json Print the raw analysis JSON instead of the report.
|
|
72
|
+
--help, -h Show this help.
|
|
73
|
+
|
|
74
|
+
Operator secret (from env, first found): PREVIEW_RECONCILE_SECRET,
|
|
75
|
+
GRANTS_ADMIN_SECRET, TOT_OPERATOR_SECRET (or pass --secret). Without one, an
|
|
76
|
+
invited developer's own \`tot login\` session is used.`;
|
|
77
|
+
|
|
78
|
+
/** Parse `tot preview doctor` argv. Pure — unit-testable. */
|
|
79
|
+
export function parsePreviewDoctorArgs(argv) {
|
|
80
|
+
const a = { tenant: null, url: null, secret: null, json: false, help: false };
|
|
81
|
+
for (let i = 0; i < argv.length; i++) {
|
|
82
|
+
const t = argv[i];
|
|
83
|
+
if (t === "--tenant") a.tenant = argv[++i];
|
|
84
|
+
else if (t === "--url") a.url = argv[++i];
|
|
85
|
+
else if (t === "--secret") a.secret = argv[++i];
|
|
86
|
+
else if (t === "--json") a.json = true;
|
|
87
|
+
else if (t === "--help" || t === "-h") a.help = true;
|
|
88
|
+
}
|
|
89
|
+
return a;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The render/normalize helpers (normalizeAnalysis / attentionBanner /
|
|
93
|
+
// supportContext / formatReport) are the shared port in ../merge-doctor-report.mjs,
|
|
94
|
+
// imported at the top of this file.
|
|
95
|
+
|
|
96
|
+
// ── run ────────────────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Fetch + render the merge-doctor report for one tenant. Resolves the SAME two
|
|
100
|
+
* transports `tot accept` uses (operator secret → generic origin + X-Tot-Owner;
|
|
101
|
+
* else viewer session → tenant host + cookie), GETs the hosted seam, and prints the
|
|
102
|
+
* ordered report (or raw JSON). `fetch`/`resolveViewerTransport` are injected so it
|
|
103
|
+
* is unit-tested with no network. Returns the process exit code (blocker → 1).
|
|
104
|
+
*
|
|
105
|
+
* @param {{ tenant:string, secret:string, storefrontUrl?:string|null,
|
|
106
|
+
* json?:boolean, env?:NodeJS.ProcessEnv }} params
|
|
107
|
+
* @param {{ fetch?:typeof fetch, resolveViewerTransport?:typeof resolveViewerTransport }} [deps]
|
|
108
|
+
* @returns {Promise<number>}
|
|
109
|
+
*/
|
|
110
|
+
export async function runDoctor(
|
|
111
|
+
{ tenant, secret, storefrontUrl = null, json = false, env = process.env },
|
|
112
|
+
deps = {},
|
|
113
|
+
) {
|
|
114
|
+
const fetchImpl = deps.fetch || globalThis.fetch;
|
|
115
|
+
const resolveViewer = deps.resolveViewerTransport || resolveViewerTransport;
|
|
116
|
+
|
|
117
|
+
// Resolve the transport — operator secret preferred, else the developer's own
|
|
118
|
+
// viewer session on the tenant host.
|
|
119
|
+
let base;
|
|
120
|
+
let authHeaders;
|
|
121
|
+
if (secret) {
|
|
122
|
+
base = (storefrontUrl || DEFAULT_STOREFRONT_URL).trim().replace(/\/+$/, "");
|
|
123
|
+
authHeaders = {
|
|
124
|
+
authorization: `Bearer ${secret}`,
|
|
125
|
+
"x-tot-owner": tenant,
|
|
126
|
+
"x-tot-capability": "ship-on-behalf",
|
|
127
|
+
};
|
|
128
|
+
} else {
|
|
129
|
+
const viewer = await resolveViewer({ tenant, env, fetchImpl });
|
|
130
|
+
if (!viewer.ok) {
|
|
131
|
+
console.error(fail(viewer.message, viewer.hint));
|
|
132
|
+
return 2;
|
|
133
|
+
}
|
|
134
|
+
base = viewer.base;
|
|
135
|
+
authHeaders = viewer.authHeaders;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let res;
|
|
139
|
+
try {
|
|
140
|
+
res = await fetchImpl(`${base}${DOCTOR_PATH}`, { method: "GET", headers: authHeaders });
|
|
141
|
+
} catch (e) {
|
|
142
|
+
console.error(
|
|
143
|
+
fail(
|
|
144
|
+
`couldn't reach the merge-doctor at ${base}: ${String(e?.message || e)}`,
|
|
145
|
+
"check --url / your network, then re-run",
|
|
146
|
+
),
|
|
147
|
+
);
|
|
148
|
+
return 1;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let data = {};
|
|
152
|
+
try {
|
|
153
|
+
data = await res.json();
|
|
154
|
+
} catch {
|
|
155
|
+
/* non-JSON / empty body */
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Session gating: the endpoint relays the queue read's auth refusal verbatim, so a
|
|
159
|
+
// caller who cannot see the accept queue cannot diagnose it.
|
|
160
|
+
if (res.status === 401 || res.status === 403) {
|
|
161
|
+
console.error(
|
|
162
|
+
fail(
|
|
163
|
+
`not authorized to diagnose ${tenant}'s merges: ${data?.error || `HTTP ${res.status}`}`,
|
|
164
|
+
"you need a live ship-on-behalf grant on this tenant (ask the store owner), or an operator secret authorised for it — then re-run",
|
|
165
|
+
),
|
|
166
|
+
);
|
|
167
|
+
return 2;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!res.ok) {
|
|
171
|
+
console.error(
|
|
172
|
+
fail(
|
|
173
|
+
`the merge-doctor did not answer for ${tenant}: ${data?.error || `HTTP ${res.status}`}`,
|
|
174
|
+
"check --tenant / --url, then re-run",
|
|
175
|
+
),
|
|
176
|
+
);
|
|
177
|
+
return 1;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const analysis = normalizeAnalysis(data);
|
|
181
|
+
|
|
182
|
+
if (json) {
|
|
183
|
+
console.log(JSON.stringify(analysis, null, 2));
|
|
184
|
+
} else {
|
|
185
|
+
console.log(formatReport(analysis));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Mirror the scripts driver: any blocker fails the run so a CI gate catches it.
|
|
189
|
+
return analysis.counts.blocker > 0 ? 1 : 0;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* @param {string[]} argv
|
|
194
|
+
* @param {any} ctx — detected CLI context (ctx.tenant when in a checkout)
|
|
195
|
+
*/
|
|
196
|
+
export async function run(argv, ctx) {
|
|
197
|
+
const env = process.env;
|
|
198
|
+
const args = parsePreviewDoctorArgs(argv);
|
|
199
|
+
if (args.help) {
|
|
200
|
+
console.log(USAGE);
|
|
201
|
+
return 0;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const tenant = (args.tenant || ctx?.tenant || "").trim();
|
|
205
|
+
if (!tenant) {
|
|
206
|
+
console.error(
|
|
207
|
+
fail(
|
|
208
|
+
"no target tenant.",
|
|
209
|
+
"pass --tenant <appDomain> (e.g. --tenant tokenoftrust.com), or run inside a store checkout.",
|
|
210
|
+
),
|
|
211
|
+
);
|
|
212
|
+
return 2;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const storefrontUrl =
|
|
216
|
+
args.url || env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL;
|
|
217
|
+
|
|
218
|
+
return await runDoctor({
|
|
219
|
+
tenant,
|
|
220
|
+
secret: resolveOperatorSecret(args.secret, env),
|
|
221
|
+
storefrontUrl,
|
|
222
|
+
json: args.json,
|
|
223
|
+
env,
|
|
224
|
+
});
|
|
225
|
+
}
|
package/src/commands/preview.mjs
CHANGED
|
@@ -59,6 +59,14 @@ export async function run(argv, ctx, { alias = null } = {}) {
|
|
|
59
59
|
return runBuild(argv.slice(1), ctx);
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
// `tot preview doctor …` is the read-only merge DIAGNOSIS subcommand (unit C1) —
|
|
63
|
+
// a distinct verb from the developer preview flow, dispatched before the submit-
|
|
64
|
+
// flow arg parse (like `build`). Only the first-class `tot preview` carries it.
|
|
65
|
+
if (!alias && argv[0] === "doctor") {
|
|
66
|
+
const { run: runDoctor } = await import("./preview-doctor.mjs");
|
|
67
|
+
return runDoctor(argv.slice(1), ctx);
|
|
68
|
+
}
|
|
69
|
+
|
|
62
70
|
const verb = alias || "preview";
|
|
63
71
|
const args = parseArgs(argv);
|
|
64
72
|
|
package/src/commands/ship.mjs
CHANGED
|
@@ -48,6 +48,11 @@ import { planForAction, printPlanAndConfirm } from "../plan.mjs";
|
|
|
48
48
|
import { startProgress } from "../progress.mjs";
|
|
49
49
|
import { openBrowser } from "../open.mjs";
|
|
50
50
|
import { emitActivity } from "../activity.mjs";
|
|
51
|
+
// C5 — the doctor PUSH: on a refused/failed ship (most tellingly "not green"),
|
|
52
|
+
// auto-append the COMPACT summary over the SAME operator transport, so the operator
|
|
53
|
+
// sees WHICH changes are keeping the aggregate from going green without a separate
|
|
54
|
+
// `tot preview doctor` run.
|
|
55
|
+
import { autoSurfaceDoctor } from "../merge-doctor-report.mjs";
|
|
51
56
|
|
|
52
57
|
const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
|
|
53
58
|
|
|
@@ -327,6 +332,14 @@ export async function runShip({ tenant, secret, storefrontUrl = null, yes = fals
|
|
|
327
332
|
"x-tot-capability": "ship-on-behalf",
|
|
328
333
|
};
|
|
329
334
|
|
|
335
|
+
// C5 — auto-surface the merge-doctor on a refused/failed ship. Best-effort:
|
|
336
|
+
// fetches the hosted doctor over the SAME operator transport and prints the COMPACT
|
|
337
|
+
// summary; never throws and never touches the exit code. A clean/unreachable doctor
|
|
338
|
+
// prints nothing — so this only ever ADDS the "here's what's not green" context.
|
|
339
|
+
const surfaceDoctor = async () => {
|
|
340
|
+
for (const line of await autoSurfaceDoctor({ base, authHeaders, fetchImpl })) console.error(line);
|
|
341
|
+
};
|
|
342
|
+
|
|
330
343
|
// 1. GET the read-only plan: the pinned sha + artifact digest + included PRs +
|
|
331
344
|
// rollback target + paywall verdict. Zero side effects.
|
|
332
345
|
let planRes;
|
|
@@ -356,6 +369,9 @@ export async function runShip({ tenant, secret, storefrontUrl = null, yes = fals
|
|
|
356
369
|
const plan = normalizeShipPlan(planData);
|
|
357
370
|
if (!plan.ok) {
|
|
358
371
|
console.error(fail(plan.message, refusalNextStep(plan.reason)));
|
|
372
|
+
// A not-green / no-passed-run refusal is exactly what the doctor diagnoses —
|
|
373
|
+
// name the changes keeping the aggregate red right here.
|
|
374
|
+
await surfaceDoctor();
|
|
359
375
|
return 1;
|
|
360
376
|
}
|
|
361
377
|
|
|
@@ -433,7 +449,9 @@ export async function runShip({ tenant, secret, storefrontUrl = null, yes = fals
|
|
|
433
449
|
payload: { args: { command: "ship", subcommand: "ship.publish" } },
|
|
434
450
|
});
|
|
435
451
|
|
|
436
|
-
|
|
452
|
+
const code = reportShipResult(shipResult, { tenant, liveUrl, noOpen, openUrl: deps.openUrl });
|
|
453
|
+
if (code !== 0) await surfaceDoctor();
|
|
454
|
+
return code;
|
|
437
455
|
}
|
|
438
456
|
|
|
439
457
|
/**
|
package/src/commands/submit.mjs
CHANGED
|
@@ -47,6 +47,10 @@ import { setTimeout as delay } from "node:timers/promises";
|
|
|
47
47
|
import { createMcpClient } from "../mcp.mjs";
|
|
48
48
|
import { establishSession, AuthUnavailableError } from "../auth.mjs";
|
|
49
49
|
import { checkoutTenant } from "./clone.mjs";
|
|
50
|
+
// Reuse the operator side's `candidate_refresh` result normaliser (unit c3 — the
|
|
51
|
+
// born-rebased submit rebuilds a candidate onto the current base with the SAME
|
|
52
|
+
// engine `tot accept --refresh` uses, so the two read its result identically).
|
|
53
|
+
import { normalizeRefreshResult } from "./accept.mjs";
|
|
50
54
|
import { validateTenant, ERROR } from "../validate.mjs";
|
|
51
55
|
import { openBrowser } from "../open.mjs";
|
|
52
56
|
import { startProgress } from "../progress.mjs";
|
|
@@ -107,8 +111,8 @@ export function parseArgs(argv) {
|
|
|
107
111
|
// `ref: null` — an explicit `--ref` always wins; otherwise the push target is
|
|
108
112
|
// derived per-run as YOUR OWN isolated candidate ref (resolvePushRef, below),
|
|
109
113
|
// never a fixed shared default.
|
|
110
|
-
/** @type {{ mcp: string|null, identity: string|null, ref: string|null, skipValidate: boolean, skipFreshness: boolean, noWait: boolean, watch: boolean, noOpen: boolean, noCommit: boolean, message: string|null, summary: string|null, summaryFile: string|null, json: boolean,
|
|
111
|
-
const a = { mcp: null, identity: null, ref: null, skipValidate: false, skipFreshness: false, noWait: false, watch: false, noOpen: false, noCommit: false, message: null, summary: null, summaryFile: null, json: false,
|
|
114
|
+
/** @type {{ mcp: string|null, identity: string|null, ref: string|null, skipValidate: boolean, skipFreshness: boolean, noWait: boolean, watch: boolean, noOpen: boolean, noCommit: boolean, message: string|null, summary: string|null, summaryFile: string|null, strategy: string|null, json: boolean, forkCandidate: boolean, help: boolean }} */
|
|
115
|
+
const a = { mcp: null, identity: null, ref: null, skipValidate: false, skipFreshness: false, noWait: false, watch: false, noOpen: false, noCommit: false, message: null, summary: null, summaryFile: null, strategy: null, json: false, forkCandidate: false, help: false };
|
|
112
116
|
for (let i = 0; i < argv.length; i++) {
|
|
113
117
|
const t = argv[i];
|
|
114
118
|
if (t === "--mcp") a.mcp = argv[++i];
|
|
@@ -117,6 +121,7 @@ export function parseArgs(argv) {
|
|
|
117
121
|
else if (t === "-m" || t === "--message") a.message = argv[++i];
|
|
118
122
|
else if (t === "--summary") a.summary = argv[++i];
|
|
119
123
|
else if (t === "--summary-file") a.summaryFile = argv[++i];
|
|
124
|
+
else if (t === "--strategy") a.strategy = argv[++i];
|
|
120
125
|
else if (t === "--json") a.json = true;
|
|
121
126
|
else if (t === "--skip-validate") a.skipValidate = true;
|
|
122
127
|
else if (t === "--skip-freshness") a.skipFreshness = true;
|
|
@@ -124,7 +129,7 @@ export function parseArgs(argv) {
|
|
|
124
129
|
else if (t === "--no-wait") a.noWait = true;
|
|
125
130
|
else if (t === "--watch") a.watch = true;
|
|
126
131
|
else if (t === "--no-open") a.noOpen = true;
|
|
127
|
-
else if (t === "--
|
|
132
|
+
else if (t === "--fork-candidate") a.forkCandidate = true;
|
|
128
133
|
else if (t === "--help" || t === "-h") a.help = true;
|
|
129
134
|
}
|
|
130
135
|
return a;
|
|
@@ -140,11 +145,20 @@ export function renderUsage(verb = "preview") {
|
|
|
140
145
|
return `tot ${verb} — submit your store for preview
|
|
141
146
|
|
|
142
147
|
tot ${verb} validate → push the preview ref → stream the result
|
|
143
|
-
tot ${verb} --
|
|
148
|
+
tot ${verb} --fork-candidate open a SECOND, independently-tracked candidate (a
|
|
149
|
+
parallel dev path). Rarely needed — a fresh git
|
|
150
|
+
branch already gets its own candidate automatically
|
|
151
|
+
(git checkout is the PR switcher); use this only to
|
|
152
|
+
run two candidates from ONE branch.
|
|
144
153
|
tot ${verb} --watch stay attached through reconcile + compliance + accept (long-poll)
|
|
145
154
|
tot ${verb} --skip-validate push without the local lint (not recommended)
|
|
146
|
-
tot ${verb} --skip-freshness skip the stale-base check
|
|
147
|
-
candidate rooted in an
|
|
155
|
+
tot ${verb} --skip-freshness skip the stale-base check AND the born-rebased rebuild
|
|
156
|
+
(not recommended — may build a candidate rooted in an
|
|
157
|
+
already-superseded base)
|
|
158
|
+
tot ${verb} --strategy <s> how the born-rebased rebuild resolves a file changed on
|
|
159
|
+
BOTH sides when the base has moved: "merge" (real 3-way
|
|
160
|
+
merge, surfaces a resolve card on a genuine overlap — the
|
|
161
|
+
default), "ours" (keep yours), "theirs" (keep the store's)
|
|
148
162
|
tot ${verb} --no-commit don't auto-commit a dirty tree — preview only what's already committed
|
|
149
163
|
tot ${verb} --ref <name> push ref (default: your own isolated candidate ref — see \`tot pr\`)
|
|
150
164
|
tot ${verb} -m "<title>" one-line summary of what changed (the approver sees this)
|
|
@@ -162,13 +176,14 @@ export function renderUsage(verb = "preview") {
|
|
|
162
176
|
tot ${verb} --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
|
|
163
177
|
|
|
164
178
|
By default a re-run UPDATES your open candidate PR (like pushing more commits
|
|
165
|
-
to a GitHub PR), rather than opening a new one each time.
|
|
166
|
-
|
|
167
|
-
open candidates with \`tot pr\` (list / view / close). If your candidate was already
|
|
179
|
+
to a GitHub PR), rather than opening a new one each time. Manage your open
|
|
180
|
+
candidates with \`tot pr\` (list / view / close). If your candidate was already
|
|
168
181
|
merged or closed, a re-run automatically opens a fresh one.
|
|
169
182
|
|
|
170
|
-
|
|
171
|
-
|
|
183
|
+
Starting a separate change? \`git checkout -b <branch>\` — a fresh branch gets its
|
|
184
|
+
own candidate automatically, and \`git checkout\` back and forth is how you switch
|
|
185
|
+
between them. --fork-candidate is an escape hatch for the rarer case of wanting a
|
|
186
|
+
SECOND candidate off the SAME branch; reach for a branch first.
|
|
172
187
|
|
|
173
188
|
Once a preview reconciles cleanly, \`tot ship\` promotes it live.
|
|
174
189
|
|
|
@@ -350,7 +365,7 @@ export const FRESHNESS_BASE_BRANCH = "preview";
|
|
|
350
365
|
* Detect a STALE local view of the base branch before minting a candidate —
|
|
351
366
|
* the live-repeat incident this guards against: the checkout's `origin/preview`
|
|
352
367
|
* tracking ref was stale (recorded before a just-merged PR moved it), so a
|
|
353
|
-
* fresh `tot preview --
|
|
368
|
+
* fresh `tot preview --fork-candidate` built a candidate rooted in the OLD tip and got an
|
|
354
369
|
* instant, entirely avoidable "not mergeable" the moment it was compared
|
|
355
370
|
* against the real, already-advanced `preview`.
|
|
356
371
|
*
|
|
@@ -386,6 +401,166 @@ export function detectStaleBase(git, branch) {
|
|
|
386
401
|
return remoteSha;
|
|
387
402
|
}
|
|
388
403
|
|
|
404
|
+
/**
|
|
405
|
+
* How many commits the base branch has advanced since this candidate forked off it
|
|
406
|
+
* (unit c2 — the shift-left base-drift warning). Conflicts in this loop are BASE
|
|
407
|
+
* DRIFT: a candidate branches off `<base>`, sits while OTHER candidates advance
|
|
408
|
+
* `<base>`, then settles mergeable=false at Accept time. This measures that drift
|
|
409
|
+
* cheaply and LOCALLY — the merge-base (fork point) of HEAD and the base's
|
|
410
|
+
* remote-tracking tip, then how many commits separate that fork point from the tip.
|
|
411
|
+
*
|
|
412
|
+
* PURE LOCAL git — reads `refs/remotes/origin/<branch>` (the last-fetched tip),
|
|
413
|
+
* never the network; it complements detectStaleBase (which confirms that tracking
|
|
414
|
+
* ref is itself current). Returns 0 — never a false warning — when there's nothing
|
|
415
|
+
* to compare against or the drift can't be positively determined: no base tracking
|
|
416
|
+
* ref yet, unrelated histories / no merge-base, HEAD already contains the tip, or
|
|
417
|
+
* any git failure. Non-blocking by contract: the caller warns on a positive count
|
|
418
|
+
* but always proceeds. Pure git I/O via the injected runner — unit-tested.
|
|
419
|
+
* @param {(cargs:string[])=>string} git
|
|
420
|
+
* @param {string} branch
|
|
421
|
+
* @returns {number}
|
|
422
|
+
*/
|
|
423
|
+
export function baseCommitsBehind(git, branch) {
|
|
424
|
+
const baseTip = `refs/remotes/origin/${branch}`;
|
|
425
|
+
let tip = "";
|
|
426
|
+
try {
|
|
427
|
+
tip = git(["rev-parse", "-q", "--verify", baseTip]).trim();
|
|
428
|
+
} catch {
|
|
429
|
+
return 0; // no local tracking ref for the base — nothing to compare against
|
|
430
|
+
}
|
|
431
|
+
if (!tip) return 0;
|
|
432
|
+
let mergeBase = "";
|
|
433
|
+
try {
|
|
434
|
+
mergeBase = git(["merge-base", "HEAD", baseTip]).trim();
|
|
435
|
+
} catch {
|
|
436
|
+
return 0; // unrelated histories / no HEAD — nothing meaningful to count
|
|
437
|
+
}
|
|
438
|
+
if (!mergeBase || mergeBase === tip) return 0; // HEAD already contains the base tip
|
|
439
|
+
try {
|
|
440
|
+
const n = parseInt(git(["rev-list", "--count", `${mergeBase}..${baseTip}`]).trim(), 10);
|
|
441
|
+
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
442
|
+
} catch {
|
|
443
|
+
return 0;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Resolve the git ref to diff HEAD against for the candidate's file patch (and its
|
|
449
|
+
* summary). The candidate PR is `preview + your file changes`, and `candidate_open`
|
|
450
|
+
* SERVER-CUTS the candidate branch back to the current preview tip and re-applies the
|
|
451
|
+
* patch — so the patch MUST be the FULL delta of your branch vs its fork point off
|
|
452
|
+
* `preview`, never "what changed since my last candidate push". Diffing against your
|
|
453
|
+
* own candidate tracking ref is what silently produced an empty patch (and the wrong
|
|
454
|
+
* "no file changes" skip, so no PR opened) after a push that landed but failed to open
|
|
455
|
+
* its PR: that ref already equals HEAD, so the since-last-push delta is empty even
|
|
456
|
+
* though the change vs preview is 19 files. Prefer the merge-base (fork point) with
|
|
457
|
+
* `origin/<baseBranch>`; fall back to the candidate tracking ref, then HEAD~1, then ""
|
|
458
|
+
* (single-commit `git show`) when no base ref resolves. Pure git I/O via the injected
|
|
459
|
+
* runner — unit-tested.
|
|
460
|
+
* @param {(cargs:string[])=>string} git
|
|
461
|
+
* @param {string} ref the candidate ref (e.g. "candidate/local-abc")
|
|
462
|
+
* @param {string} [baseBranch] the preview base branch (default FRESHNESS_BASE_BRANCH)
|
|
463
|
+
* @returns {string}
|
|
464
|
+
*/
|
|
465
|
+
export function resolvePatchBase(git, ref, baseBranch = FRESHNESS_BASE_BRANCH) {
|
|
466
|
+
const verify = (r) => {
|
|
467
|
+
try { return git(["rev-parse", "--verify", "--quiet", r]).trim(); } catch { return ""; }
|
|
468
|
+
};
|
|
469
|
+
const previewRef = `refs/remotes/origin/${baseBranch}`;
|
|
470
|
+
if (verify(previewRef)) {
|
|
471
|
+
try {
|
|
472
|
+
const forkPoint = git(["merge-base", "HEAD", previewRef]).trim();
|
|
473
|
+
if (forkPoint) return forkPoint;
|
|
474
|
+
} catch { /* unrelated histories — fall through */ }
|
|
475
|
+
}
|
|
476
|
+
const trackingRef = `refs/remotes/origin/${ref}`;
|
|
477
|
+
if (verify(trackingRef)) return trackingRef;
|
|
478
|
+
if (verify("HEAD~1")) return "HEAD~1";
|
|
479
|
+
return "";
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ─── born-rebased at submit (unit c3 — shift-left prevention #2) ─────────────────
|
|
483
|
+
|
|
484
|
+
/** The three strategies the born-rebased rebuild accepts, mirroring `tot accept
|
|
485
|
+
* --refresh` (accept.mjs) and the admin one-click resolver: `merge` (real 3-way,
|
|
486
|
+
* refuses on a genuine overlap), `ours` (keep yours), `theirs` (keep the store's).
|
|
487
|
+
* `merge` is the DEFAULT — auto-rebuild on pure drift, name the conflict on a real
|
|
488
|
+
* same-line overlap, never silently clobber a side. */
|
|
489
|
+
export const BORN_REBASED_STRATEGIES = ["ours", "theirs", "merge"];
|
|
490
|
+
export const DEFAULT_BORN_REBASED_STRATEGY = "merge";
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Run `candidate_refresh` (the born-rebased rebuild, unit c3) over the ALREADY
|
|
494
|
+
* established MCP client — the candidate was just opened by candidate_open (step 2b)
|
|
495
|
+
* and the tenant scope is already bound, so this reuses that session rather than
|
|
496
|
+
* establishing its own (accept.mjs's runRefresh is the operator entry that does the
|
|
497
|
+
* sign-in; here the submit flow already holds the session). Rebuilds the candidate
|
|
498
|
+
* from the CURRENT base tip and re-applies its file changes under `strategy`. Returns
|
|
499
|
+
* the normalized result (status:"committed" is the only success). Best-effort: any
|
|
500
|
+
* throw — an owner-gated denial (candidate_refresh is app-owner gated), an older MCP
|
|
501
|
+
* without the tool, a transient failure — normalizes to a non-ok error result the
|
|
502
|
+
* caller submits-as-is on, NEVER blocking the push that already landed.
|
|
503
|
+
* @param {{callTool:Function}} client
|
|
504
|
+
* @param {{ repo: string, changeId: string, strategy: string }} opts
|
|
505
|
+
* @returns {Promise<ReturnType<typeof normalizeRefreshResult>>}
|
|
506
|
+
*/
|
|
507
|
+
export async function runBornRebased(client, { repo, changeId, strategy }) {
|
|
508
|
+
try {
|
|
509
|
+
const raw = await client.callTool("candidate_refresh", { repo, changeId, strategy });
|
|
510
|
+
return normalizeRefreshResult(raw);
|
|
511
|
+
} catch (e) {
|
|
512
|
+
return { ...normalizeRefreshResult(null), status: "error", message: String(e?.message || e) };
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* The born-rebased SUCCESS lines (unit c3) — printed when the candidate rebuilt
|
|
518
|
+
* cleanly onto the current base tip, so it enters the queue already mergeable rather
|
|
519
|
+
* than settling not-mergeable at Accept time. `behind` is the base-drift count that
|
|
520
|
+
* triggered the rebuild. Pure — unit-tested.
|
|
521
|
+
* @param {{ behind?: number, strategy?: string, refreshedFiles?: string[] }} input
|
|
522
|
+
* @returns {string[]}
|
|
523
|
+
*/
|
|
524
|
+
export function formatBornRebasedSuccess({ behind = 0, strategy = DEFAULT_BORN_REBASED_STRATEGY, refreshedFiles = [] } = {}) {
|
|
525
|
+
const drift = behind > 0 ? `${behind} commit${behind === 1 ? "" : "s"}` : "since you forked";
|
|
526
|
+
const lines = [
|
|
527
|
+
`\n ✓ rebuilt fresh on the current store (base had advanced ${drift}, strategy ${strategy}) — your candidate enters the queue already mergeable.`,
|
|
528
|
+
];
|
|
529
|
+
if (refreshedFiles.length) lines.push(` reapplied: ${refreshedFiles.join(", ")}`);
|
|
530
|
+
return lines;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* The born-rebased RESOLVE CARD (unit c3) — printed when the default `merge` rebuild
|
|
535
|
+
* hits a GENUINE same-line overlap with the base (not mere drift): the biggest single
|
|
536
|
+
* shift-left win is surfacing that conflict HERE, at submit, instead of letting it
|
|
537
|
+
* land as a stuck queue row someone discovers at Accept time. Names the diverged files
|
|
538
|
+
* and offers the two one-command resolutions (keep-mine / keep-store's) — `merge` is
|
|
539
|
+
* the default that just failed, so it isn't re-offered. Matches how the rest of this
|
|
540
|
+
* file reports next-steps (a `✗` headline + concrete `tot <verb> …` commands), never a
|
|
541
|
+
* raw git rebase instruction. Pure — unit-tested. `verb` brands the copy with whatever
|
|
542
|
+
* the developer typed.
|
|
543
|
+
* @param {{ unresolved?: string[] }} rr
|
|
544
|
+
* @param {string} [verb]
|
|
545
|
+
* @returns {string[]}
|
|
546
|
+
*/
|
|
547
|
+
export function formatBornRebasedConflict({ unresolved = [] } = {}, verb = "preview") {
|
|
548
|
+
const lines = [
|
|
549
|
+
`\n ✗ your change conflicts with the current store on the same lines — it can't be auto-rebased.`,
|
|
550
|
+
];
|
|
551
|
+
if (unresolved.length) {
|
|
552
|
+
lines.push(` These files changed on both sides since you forked and need your call:`);
|
|
553
|
+
for (const f of unresolved) lines.push(` - ${f}`);
|
|
554
|
+
}
|
|
555
|
+
lines.push(
|
|
556
|
+
` Resolve it in one command — choose which side wins on those files:`,
|
|
557
|
+
` tot ${verb} --strategy=ours keep YOURS on any clash`,
|
|
558
|
+
` tot ${verb} --strategy=theirs keep the STORE's on any clash`,
|
|
559
|
+
` Your push is in; the candidate is submitted but stays not-mergeable until you resolve it.`,
|
|
560
|
+
);
|
|
561
|
+
return lines;
|
|
562
|
+
}
|
|
563
|
+
|
|
389
564
|
// ─── auto-commit the known content trees (unit u2) ───────────────────────────────
|
|
390
565
|
|
|
391
566
|
/**
|
|
@@ -783,22 +958,23 @@ export function actorKeyFor(session) {
|
|
|
783
958
|
* Which candidate this submit lands on (gh-pr-like) — decided UP FRONT, before any
|
|
784
959
|
* network call, because it also determines the isolated git ref we push to
|
|
785
960
|
* (resolvePushRef, below): a re-submit updates the SAME candidate/ref by default;
|
|
786
|
-
* `--
|
|
787
|
-
*
|
|
788
|
-
* otherwise
|
|
789
|
-
*
|
|
961
|
+
* `--fork-candidate` forks a fresh one.
|
|
962
|
+
* forkCandidate → fork a FRESH candidate id;
|
|
963
|
+
* otherwise → the remembered active candidate (from a prior --fork-candidate /
|
|
964
|
+
* terminal roll), else the STABLE per-dev-per-tenant(-per-branch)
|
|
965
|
+
* default.
|
|
790
966
|
* `persist` reports whether the choice diverges from the stable default, so the
|
|
791
967
|
* caller knows whether to remember it as the new active pointer. `mint` is
|
|
792
968
|
* injected (defaults to mintFreshChangeId) so this is pure/deterministic in tests.
|
|
793
969
|
* Pure — unit-tested.
|
|
794
970
|
* @param {{ tenant: string, actorKey: string, branch?: string|null, active?: string|null,
|
|
795
|
-
*
|
|
971
|
+
* forkCandidate?: boolean, mint?: (baseId: string) => string }} opts
|
|
796
972
|
* @returns {{ changeId: string, stableId: string, persist: boolean }}
|
|
797
973
|
*/
|
|
798
|
-
export function chooseChangeId({ tenant, actorKey, branch = null, active = null,
|
|
974
|
+
export function chooseChangeId({ tenant, actorKey, branch = null, active = null, forkCandidate = false, mint = mintFreshChangeId }) {
|
|
799
975
|
const stableId = deriveChangeId(tenant, actorKey, branch);
|
|
800
|
-
const changeId =
|
|
801
|
-
const persist =
|
|
976
|
+
const changeId = forkCandidate ? mint(stableId) : (active || stableId);
|
|
977
|
+
const persist = forkCandidate || (!!active && active !== stableId);
|
|
802
978
|
return { changeId, stableId, persist };
|
|
803
979
|
}
|
|
804
980
|
|
|
@@ -1019,6 +1195,15 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1019
1195
|
emitJson(args, buildJsonResult({ ok: false, error: msg }));
|
|
1020
1196
|
return 2;
|
|
1021
1197
|
}
|
|
1198
|
+
// --strategy (c3 born-rebased rebuild) — validate whenever given; defaults to
|
|
1199
|
+
// "merge" (auto-rebuild on drift, surface a resolve card on a genuine overlap).
|
|
1200
|
+
if (args.strategy != null && !BORN_REBASED_STRATEGIES.includes(args.strategy)) {
|
|
1201
|
+
const msg = `unknown --strategy "${args.strategy}"`;
|
|
1202
|
+
console.error(fail(msg, `use one of: ${BORN_REBASED_STRATEGIES.join(", ")} (default "${DEFAULT_BORN_REBASED_STRATEGY}")`));
|
|
1203
|
+
emitJson(args, buildJsonResult({ ok: false, error: msg }));
|
|
1204
|
+
return 2;
|
|
1205
|
+
}
|
|
1206
|
+
const bornRebasedStrategy = args.strategy || DEFAULT_BORN_REBASED_STRATEGY;
|
|
1022
1207
|
if (args.summaryFile) {
|
|
1023
1208
|
let raw;
|
|
1024
1209
|
try {
|
|
@@ -1054,6 +1239,12 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1054
1239
|
/* best-effort — see above */
|
|
1055
1240
|
}
|
|
1056
1241
|
|
|
1242
|
+
// How far the base has drifted since this candidate forked (unit c2), hoisted to the
|
|
1243
|
+
// whole flow: it drives BOTH the c2 pre-push warning (below) AND the c3 born-rebased
|
|
1244
|
+
// rebuild (after candidate_open). Stays 0 under --skip-freshness, so that flag opts
|
|
1245
|
+
// out of the rebuild too — consistent with opting out of the warning.
|
|
1246
|
+
let baseDrift = 0;
|
|
1247
|
+
|
|
1057
1248
|
// Freshness preflight (unit u16) — BEFORE minting anything: is the checkout's
|
|
1058
1249
|
// cached view of the base branch already behind the store? A candidate built
|
|
1059
1250
|
// on a stale base is an instant, avoidable "not mergeable" the moment the
|
|
@@ -1074,6 +1265,24 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1074
1265
|
emitJson(args, buildJsonResult({ ok: false, error: msg }));
|
|
1075
1266
|
return 1;
|
|
1076
1267
|
}
|
|
1268
|
+
|
|
1269
|
+
// Base-drift warning (unit c2) — a candidate rooted on a fork point the base has
|
|
1270
|
+
// since moved past is what settles mergeable=false at Accept time; catch it early,
|
|
1271
|
+
// right here, with a pure-LOCAL git read (no network — uses the base's
|
|
1272
|
+
// remote-tracking tip, which the stale-base preflight just above confirmed is
|
|
1273
|
+
// current). NON-BLOCKING: warn and submit anyway, so a work-in-progress preview
|
|
1274
|
+
// is never refused over base drift. Shares --skip-freshness with the preflight.
|
|
1275
|
+
try {
|
|
1276
|
+
baseDrift = baseCommitsBehind(git, FRESHNESS_BASE_BRANCH);
|
|
1277
|
+
} catch {
|
|
1278
|
+
baseDrift = 0; // never let the warning's OWN failure disturb the submit
|
|
1279
|
+
}
|
|
1280
|
+
if (baseDrift > 0) {
|
|
1281
|
+
console.error(`\n⚠ base is ${baseDrift} commit${baseDrift === 1 ? "" : "s"} behind — rebase before submit`);
|
|
1282
|
+
console.error(
|
|
1283
|
+
` (\`${FRESHNESS_BASE_BRANCH}\` has advanced since your branch forked off it — \`tot sync\` to rebase, or --skip-freshness to silence)`,
|
|
1284
|
+
);
|
|
1285
|
+
}
|
|
1077
1286
|
}
|
|
1078
1287
|
|
|
1079
1288
|
|
|
@@ -1181,12 +1390,12 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1181
1390
|
};
|
|
1182
1391
|
function buildSummaryAndPatch(ref) {
|
|
1183
1392
|
const headSubject = gitSafe(["log", "-1", "--format=%s"]).trim();
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1393
|
+
// Diff against the fork point off `preview` — the FULL branch delta — so the patch
|
|
1394
|
+
// is complete for candidate_open's server-cut (which resets the branch to preview
|
|
1395
|
+
// and re-applies this patch). NOT the candidate's own tracking ref: after a push
|
|
1396
|
+
// that landed but failed to open its PR, that ref equals HEAD → empty patch →
|
|
1397
|
+
// wrong "no file changes" → no PR. See resolvePatchBase.
|
|
1398
|
+
const base = resolvePatchBase(gitSafe, ref);
|
|
1190
1399
|
const statusCmd = base ? ["diff", "--name-status", `${base}..HEAD`] : ["show", "--name-status", "--format=", "HEAD"];
|
|
1191
1400
|
const patchEntries = parseNameStatus(gitSafe(statusCmd));
|
|
1192
1401
|
const files = patchEntries.map((e) => e.path);
|
|
@@ -1221,7 +1430,7 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1221
1430
|
// still lands if that embedded token is live. actorKeyFor(null) degrades to the
|
|
1222
1431
|
// generic "developer" key — still isolated PER BRANCH (never the shared ref),
|
|
1223
1432
|
// just not per-developer until sign-in succeeds.
|
|
1224
|
-
const { changeId } = chooseChangeId({ tenant, actorKey: actorKeyFor(null), branch, active,
|
|
1433
|
+
const { changeId } = chooseChangeId({ tenant, actorKey: actorKeyFor(null), branch, active, forkCandidate: args.forkCandidate });
|
|
1225
1434
|
const ref = resolvePushRef({ ref: args.ref, changeId });
|
|
1226
1435
|
const { changeSummary } = buildSummaryAndPatch(ref);
|
|
1227
1436
|
console.error(`~ pushing ${short} → ${ref} (origin)`);
|
|
@@ -1264,11 +1473,11 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1264
1473
|
// If it merged/closed we DROP it (and forget it on disk) so chooseChangeId falls
|
|
1265
1474
|
// back to the stable id, rather than force-pushing onto a now-dead candidate branch
|
|
1266
1475
|
// and opening a NEW PR that inherits a guaranteed conflict (the live incident this
|
|
1267
|
-
// guards against). Skipped under --
|
|
1268
|
-
// anyway). Purely diagnostic: a check that errors leaves the pointer untouched.
|
|
1476
|
+
// guards against). Skipped under --fork-candidate (chooseChangeId ignores `active`
|
|
1477
|
+
// there anyway). Purely diagnostic: a check that errors leaves the pointer untouched.
|
|
1269
1478
|
// Needs the tenant scope bound for candidate_status to resolve — idempotent with
|
|
1270
1479
|
// the later client_switch / the fresh-mint checkoutTenant.
|
|
1271
|
-
if (active && repo && !args.
|
|
1480
|
+
if (active && repo && !args.forkCandidate) {
|
|
1272
1481
|
try {
|
|
1273
1482
|
await client.callTool("client_switch", { tenant });
|
|
1274
1483
|
} catch { /* scope bind is best-effort; candidateStateFor tolerates a miss */ }
|
|
@@ -1287,9 +1496,9 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1287
1496
|
// Which candidate (and therefore which isolated ref, b03) this submit targets —
|
|
1288
1497
|
// decided now, with a real session, so the SAME id backs both the raw git push
|
|
1289
1498
|
// (right below) and the PR-backed candidate (step 2b): the two never point at
|
|
1290
|
-
// different branches. See chooseChangeId's doc for the --
|
|
1291
|
-
// rules.
|
|
1292
|
-
let { changeId, stableId, persist } = chooseChangeId({ tenant, actorKey: actorKeyFor(session), branch, active,
|
|
1499
|
+
// different branches. See chooseChangeId's doc for the --fork-candidate /
|
|
1500
|
+
// active-pointer rules.
|
|
1501
|
+
let { changeId, stableId, persist } = chooseChangeId({ tenant, actorKey: actorKeyFor(session), branch, active, forkCandidate: args.forkCandidate });
|
|
1293
1502
|
const ref = resolvePushRef({ ref: args.ref, changeId });
|
|
1294
1503
|
const { changeSummary, patchEntries } = buildSummaryAndPatch(ref);
|
|
1295
1504
|
|
|
@@ -1357,6 +1566,61 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1357
1566
|
candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob, quiet: args.json });
|
|
1358
1567
|
}
|
|
1359
1568
|
|
|
1569
|
+
// c3 — BORN-REBASED at submit (shift-left prevention #2). The candidate is open;
|
|
1570
|
+
// if the base has drifted (the SAME signal c2 warned on, pre-push) rebuild it from
|
|
1571
|
+
// the CURRENT base tip via candidate_refresh BEFORE finalizing, so it enters the
|
|
1572
|
+
// queue already fresh instead of settling not-mergeable at Accept time.
|
|
1573
|
+
// • clean rebuild → report it, hand back the fresh candidate's shareable URL,
|
|
1574
|
+
// and finish (the rebuilt PR has a NEW head; polling the old
|
|
1575
|
+
// local `commit` would read as never-dispatched, so we skip
|
|
1576
|
+
// the reconcile poll and say the fresh preview is building);
|
|
1577
|
+
// • genuine overlap (merge_failed) → surface the resolve card right here, then
|
|
1578
|
+
// fall through — candidate_refresh made NO changes on a real
|
|
1579
|
+
// conflict, so the candidate's head still matches `commit`
|
|
1580
|
+
// and the normal reconcile poll below is still valid;
|
|
1581
|
+
// • anything else (owner-gated denial, older MCP, error) → submit as-is.
|
|
1582
|
+
// Best-effort throughout: candidate_refresh is app-owner gated, so an ordinary
|
|
1583
|
+
// invited developer's session may be denied — that degrades to submitting as-is,
|
|
1584
|
+
// never blocking the push that already landed. Gated on --skip-freshness via
|
|
1585
|
+
// baseDrift (0 when skipped).
|
|
1586
|
+
if (repo && baseDrift > 0 && candidate && !isTerminalCandidateState(candidate.state) && changeId) {
|
|
1587
|
+
const rebased = await runBornRebased(client, { repo, changeId, strategy: bornRebasedStrategy });
|
|
1588
|
+
if (rebased.ok) {
|
|
1589
|
+
for (const line of formatBornRebasedSuccess({ behind: baseDrift, strategy: rebased.strategy || bornRebasedStrategy, refreshedFiles: rebased.refreshedFiles })) {
|
|
1590
|
+
if (!args.json) console.log(line);
|
|
1591
|
+
}
|
|
1592
|
+
const freshPr = typeof rebased.prNumber === "number" ? rebased.prNumber : candidate.prNumber;
|
|
1593
|
+
const freshPrUrl = typeof freshPr === "number"
|
|
1594
|
+
? shareablePrUrl(env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL, tenant, freshPr)
|
|
1595
|
+
: null;
|
|
1596
|
+
if (freshPrUrl && !args.json) {
|
|
1597
|
+
console.log(`\n ▸ Your fresh preview will appear at:\n ${freshPrUrl}\n (building on the current store — this link goes live once reconcile completes)`);
|
|
1598
|
+
}
|
|
1599
|
+
// The rebuild keeps the STABLE changeId, so the persisted active pointer stays
|
|
1600
|
+
// valid — record it (best-effort) exactly as the normal open path does below.
|
|
1601
|
+
if (persist && repo) {
|
|
1602
|
+
try {
|
|
1603
|
+
writeActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch, changeId });
|
|
1604
|
+
} catch { /* best-effort local hint */ }
|
|
1605
|
+
}
|
|
1606
|
+
emitJson(args, buildJsonResult({
|
|
1607
|
+
ok: true, ref, commit, changeId,
|
|
1608
|
+
candidate: { ...candidate, prNumber: freshPr ?? candidate.prNumber },
|
|
1609
|
+
previewPrUrl: freshPrUrl,
|
|
1610
|
+
note: `born-rebased on the current base (${rebased.strategy || bornRebasedStrategy})`,
|
|
1611
|
+
}));
|
|
1612
|
+
return 0;
|
|
1613
|
+
}
|
|
1614
|
+
if (rebased.status === "merge_failed") {
|
|
1615
|
+
for (const line of formatBornRebasedConflict({ unresolved: rebased.unresolved }, verb)) {
|
|
1616
|
+
if (!args.json) console.log(line);
|
|
1617
|
+
}
|
|
1618
|
+
// fall through to the normal poll — the candidate is unchanged.
|
|
1619
|
+
} else if (rebased.message && !args.json) {
|
|
1620
|
+
console.log(` ~ couldn't auto-rebase on the current base (${rebased.message}) — submitting as-is.`);
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1360
1624
|
// Immediate shareable URL (Vercel-style: "the URL exists before the build
|
|
1361
1625
|
// does"). A non-terminal candidate with a real PR number means a preview
|
|
1362
1626
|
// WILL be built at a deterministic route — so hand the developer that link
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* merge-doctor-report — the SHARED, pure rendering + one-GET fetch of the hosted
|
|
3
|
+
* merge-doctor seam (`GET /<tenant>/api/preview/merge-doctor`, unit A2), so every
|
|
4
|
+
* CLI surface speaks ONE doctor taxonomy:
|
|
5
|
+
*
|
|
6
|
+
* - `tot preview doctor` (preview-doctor.mjs) — the full, ordered report on demand.
|
|
7
|
+
* - `tot accept` / `tot ship` (accept.mjs / ship.mjs) — the C5 PUSH: on a failed
|
|
8
|
+
* accept/integrate or a refused ship, auto-append the COMPACT summary right where
|
|
9
|
+
* the failure already surfaced, so a developer doesn't have to remember to run the
|
|
10
|
+
* doctor themselves.
|
|
11
|
+
*
|
|
12
|
+
* The render helpers here are a faithful PORT of the analyzer's own
|
|
13
|
+
* formatReport/attentionBanner/supportContext (mergeDoctor.ts §render / the
|
|
14
|
+
* scripts/tenant/gitea-merge-doctor.mjs mirror) — the published `@tokenoftrust/cli`
|
|
15
|
+
* is dependency-free and cannot import the app/scripts source, so this port is kept in
|
|
16
|
+
* sync with that taxonomy by hand.
|
|
17
|
+
*
|
|
18
|
+
* Dependency-light: `fail` + global fetch only. Imports NOTHING from the command
|
|
19
|
+
* modules (accept/ship/preview-doctor), so accept.mjs and ship.mjs can both depend on
|
|
20
|
+
* it without a cycle (preview-doctor.mjs already imports ship.mjs for the operator
|
|
21
|
+
* secret; routing the shared pieces through here keeps ship.mjs ⟷ preview-doctor.mjs
|
|
22
|
+
* acyclic).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** The hosted seam path — one GET, one answer (composed server-side over the SAME
|
|
26
|
+
* reads the admin Publish tab builds, run through the shared analyzer). */
|
|
27
|
+
export const DOCTOR_PATH = "/api/preview/merge-doctor";
|
|
28
|
+
|
|
29
|
+
/** Who resolves a finding — the ticket-deflection axis. */
|
|
30
|
+
export const OWNERSHIP = { developer: "developer", platform: "platform", operator: "operator" };
|
|
31
|
+
export const SEV_GLYPH = { blocker: "✗", warn: "⚠", info: "•" };
|
|
32
|
+
export const OWN_TAG = { developer: "you", platform: "on-us", operator: "housekeeping" };
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Defensively read the endpoint body into the analysis shape. The endpoint is a
|
|
36
|
+
* trust boundary, so a plausible field gap degrades rather than crashes. Pure.
|
|
37
|
+
* @param {any} data
|
|
38
|
+
* @returns {{ scopeKnown:boolean, verdict:string, findings:any[], counts:{blocker:number,warn:number,info:number} }}
|
|
39
|
+
*/
|
|
40
|
+
export function normalizeAnalysis(data) {
|
|
41
|
+
const o = data && typeof data === "object" ? data : {};
|
|
42
|
+
const findings = Array.isArray(o.findings) ? o.findings : [];
|
|
43
|
+
const c = o.counts && typeof o.counts === "object" ? o.counts : {};
|
|
44
|
+
const counts = {
|
|
45
|
+
blocker: Number.isFinite(c.blocker) ? c.blocker : findings.filter((f) => f?.severity === "blocker").length,
|
|
46
|
+
warn: Number.isFinite(c.warn) ? c.warn : findings.filter((f) => f?.severity === "warn").length,
|
|
47
|
+
info: Number.isFinite(c.info) ? c.info : findings.filter((f) => f?.severity === "info").length,
|
|
48
|
+
};
|
|
49
|
+
return {
|
|
50
|
+
scopeKnown: o.scopeKnown === true,
|
|
51
|
+
verdict: typeof o.verdict === "string" ? o.verdict : "(no verdict returned)",
|
|
52
|
+
findings,
|
|
53
|
+
counts,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The one-line banner shown at the top of the report — counts only
|
|
59
|
+
* DEVELOPER-actionable findings (what a person can self-serve); platform faults are
|
|
60
|
+
* narrated separately by {@link supportContext}. Pure.
|
|
61
|
+
*/
|
|
62
|
+
export function attentionBanner(result) {
|
|
63
|
+
const dev = result.findings.filter((f) => f.ownership === OWNERSHIP.developer && f.severity !== "info");
|
|
64
|
+
const fixable = dev.filter((f) => f.action).length;
|
|
65
|
+
if (!dev.length) {
|
|
66
|
+
const platform = result.findings.filter((f) => f.ownership === OWNERSHIP.platform && f.severity === "blocker");
|
|
67
|
+
if (platform.length) return `${platform.length} issue(s) are on us — Retry, then Report to support if they persist.`;
|
|
68
|
+
return "Nothing needs your attention.";
|
|
69
|
+
}
|
|
70
|
+
const conflicts = dev.filter((f) => f.code === "PR_CONFLICT").length;
|
|
71
|
+
const builds = dev.filter((f) => f.code === "NOT_BUILT_PR").length;
|
|
72
|
+
const bits = [];
|
|
73
|
+
if (conflicts) bits.push(`${conflicts} conflict(s)${conflicts <= fixable ? " (1-click fix)" : ""}`);
|
|
74
|
+
if (builds) bits.push(`${builds} need a build`);
|
|
75
|
+
return `${dev.length} change(s) need your attention: ${bits.join(", ") || "see below"}.`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The PRE-FILLED support escalation, generated ONLY when there are PLATFORM-owned
|
|
80
|
+
* findings (a retry didn't clear it) — so "I'm stuck" becomes a structured report.
|
|
81
|
+
* Returns null when nothing is platform-owned. Pure.
|
|
82
|
+
*/
|
|
83
|
+
export function supportContext(result) {
|
|
84
|
+
const platform = result.findings.filter((f) => f.ownership === OWNERSHIP.platform);
|
|
85
|
+
if (!platform.length) return null;
|
|
86
|
+
const lines = [
|
|
87
|
+
`Tenant scope: ${result.scopeKnown ? "supplied" : "unknown"}`,
|
|
88
|
+
`Verdict: ${result.verdict}`,
|
|
89
|
+
"Platform-owned issues (a retry did not clear these — please investigate):",
|
|
90
|
+
...platform.map((f) => ` • [${f.code}] ${f.subject} — ${f.detail}`),
|
|
91
|
+
"Next diagnostic hop: scripts/preview/pipeline-doctor.sh <tenant> --commit <headSha> (reconcile plane).",
|
|
92
|
+
];
|
|
93
|
+
return lines.join("\n");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Render the analysis as the compact, ordered, agent-cheap report. Pure. */
|
|
97
|
+
export function formatReport(result) {
|
|
98
|
+
const lines = [];
|
|
99
|
+
lines.push("== gitea-merge-doctor ==");
|
|
100
|
+
lines.push(`VERDICT: ${result.verdict}`);
|
|
101
|
+
lines.push(attentionBanner(result));
|
|
102
|
+
lines.push("");
|
|
103
|
+
for (const f of result.findings) {
|
|
104
|
+
const act = f.action ? ` [action: ${f.action.label}]` : "";
|
|
105
|
+
lines.push(`${SEV_GLYPH[f.severity] ?? "?"} [${f.code}] (${OWN_TAG[f.ownership] ?? f.ownership}) ${f.subject}${act}`);
|
|
106
|
+
lines.push(` ${f.detail}`);
|
|
107
|
+
lines.push(` → ${f.remedy}`);
|
|
108
|
+
}
|
|
109
|
+
lines.push("");
|
|
110
|
+
lines.push(
|
|
111
|
+
`${result.counts.blocker} blocker(s), ${result.counts.warn} warning(s), ${result.counts.info} info. First ✗/⚠ above is the thing to fix.`,
|
|
112
|
+
);
|
|
113
|
+
const support = supportContext(result);
|
|
114
|
+
if (support) {
|
|
115
|
+
lines.push("");
|
|
116
|
+
lines.push("── if a platform issue persists after Retry, escalate with this (no free-text “stuck”): ──");
|
|
117
|
+
lines.push(support);
|
|
118
|
+
}
|
|
119
|
+
return lines.join("\n");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── C5: auto-surface on failure (the PUSH) ───────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
/** How many findings the COMPACT summary lists before it defers the rest to the
|
|
125
|
+
* full `tot preview doctor` report — enough to name the thing to fix, not the whole
|
|
126
|
+
* multi-page report at a failure moment. */
|
|
127
|
+
export const COMPACT_FINDING_LIMIT = 3;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Render the COMPACT "here's what's blocking you" summary appended to a failed
|
|
131
|
+
* accept/integrate or a refused ship (C5). Unlike {@link formatReport} (the full
|
|
132
|
+
* on-demand report), this lists ONLY the actionable (blocker/warn) findings, capped
|
|
133
|
+
* at {@link COMPACT_FINDING_LIMIT}, and points at the full report for the rest. Returns
|
|
134
|
+
* an EMPTY array when there's nothing actionable to say (a clean/info-only verdict, or
|
|
135
|
+
* no analysis at all) — so a failure whose cause the doctor can't see gets NO noise
|
|
136
|
+
* appended. Pure — returns the lines to print, never prints itself.
|
|
137
|
+
* @param {ReturnType<typeof normalizeAnalysis>|null} result
|
|
138
|
+
* @param {{ limit?: number }} [opts]
|
|
139
|
+
* @returns {string[]}
|
|
140
|
+
*/
|
|
141
|
+
export function formatCompactSummary(result, { limit = COMPACT_FINDING_LIMIT } = {}) {
|
|
142
|
+
if (!result || !Array.isArray(result.findings)) return [];
|
|
143
|
+
const actionable = result.findings.filter((f) => f?.severity === "blocker" || f?.severity === "warn");
|
|
144
|
+
if (!actionable.length) return [];
|
|
145
|
+
const lines = [`\n ── merge doctor — what's blocking your merges:`];
|
|
146
|
+
lines.push(` ${attentionBanner(result)}`);
|
|
147
|
+
for (const f of actionable.slice(0, limit)) {
|
|
148
|
+
const act = f.action?.label ? ` [${f.action.label}]` : "";
|
|
149
|
+
lines.push(` ${SEV_GLYPH[f.severity] ?? "?"} [${f.code}] (${OWN_TAG[f.ownership] ?? f.ownership}) ${f.subject}${act}`);
|
|
150
|
+
if (f.remedy) lines.push(` → ${f.remedy}`);
|
|
151
|
+
}
|
|
152
|
+
const more = actionable.length - limit;
|
|
153
|
+
lines.push(
|
|
154
|
+
more > 0
|
|
155
|
+
? ` …and ${more} more — run \`tot preview doctor\` for the full report.`
|
|
156
|
+
: ` Run \`tot preview doctor\` for the full report.`,
|
|
157
|
+
);
|
|
158
|
+
return lines;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* One GET to the hosted merge-doctor over an ALREADY-RESOLVED transport (the SAME
|
|
163
|
+
* `base` + `authHeaders` the calling verb used to reach `/api/changes` /
|
|
164
|
+
* `/api/changes/ship`), normalized to the analysis shape. Best-effort by contract:
|
|
165
|
+
* ANY failure (unreachable, non-2xx incl. the 401/403 an auth-refused caller would
|
|
166
|
+
* also hit, non-JSON body, a throw) resolves to `null` — the doctor is a diagnostic
|
|
167
|
+
* ADD-ON at a failure moment, so it must never itself become a second failure. Pure
|
|
168
|
+
* given the injected fetch.
|
|
169
|
+
* @param {{ base:string, authHeaders:Record<string,string> }} transport
|
|
170
|
+
* @param {typeof fetch} [fetchImpl]
|
|
171
|
+
* @returns {Promise<ReturnType<typeof normalizeAnalysis>|null>}
|
|
172
|
+
*/
|
|
173
|
+
export async function fetchDoctorAnalysis({ base, authHeaders }, fetchImpl = globalThis.fetch) {
|
|
174
|
+
try {
|
|
175
|
+
const res = await fetchImpl(`${String(base).replace(/\/+$/, "")}${DOCTOR_PATH}`, {
|
|
176
|
+
method: "GET",
|
|
177
|
+
headers: authHeaders,
|
|
178
|
+
});
|
|
179
|
+
if (!res || !res.ok) return null;
|
|
180
|
+
let data = {};
|
|
181
|
+
try {
|
|
182
|
+
data = await res.json();
|
|
183
|
+
} catch {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
return normalizeAnalysis(data);
|
|
187
|
+
} catch {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* C5 entry point: fetch the merge-doctor over the given transport and render the
|
|
194
|
+
* COMPACT failure summary. Returns the lines to print (or `[]` when there's nothing
|
|
195
|
+
* actionable / the doctor couldn't be reached). Never throws — a caller can
|
|
196
|
+
* `for (const l of await autoSurfaceDoctor(...)) console.error(l)` unconditionally at
|
|
197
|
+
* a failure return without touching the exit code.
|
|
198
|
+
* @param {{ base:string, authHeaders:Record<string,string>, fetchImpl?:typeof fetch, limit?:number }} opts
|
|
199
|
+
* @returns {Promise<string[]>}
|
|
200
|
+
*/
|
|
201
|
+
export async function autoSurfaceDoctor({ base, authHeaders, fetchImpl = globalThis.fetch, limit }) {
|
|
202
|
+
try {
|
|
203
|
+
const analysis = await fetchDoctorAnalysis({ base, authHeaders }, fetchImpl);
|
|
204
|
+
return formatCompactSummary(analysis, { limit });
|
|
205
|
+
} catch {
|
|
206
|
+
return [];
|
|
207
|
+
}
|
|
208
|
+
}
|