@tokenoftrust/cli 1.4.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/bin/tot.mjs +58 -79
- package/package.json +6 -1
- package/src/activity.mjs +15 -14
- package/src/app-scaffold.mjs +4 -4
- package/src/auth.mjs +13 -5
- package/src/candidate-state.mjs +3 -3
- package/src/commands/accept.mjs +65 -38
- package/src/commands/app/dev.mjs +8 -4
- package/src/commands/app/index.mjs +3 -3
- package/src/commands/app/scaffold.mjs +1 -1
- package/src/commands/branches.mjs +4 -3
- package/src/commands/cleanup.mjs +7 -11
- package/src/commands/clone.mjs +23 -20
- package/src/commands/dev.mjs +42 -24
- package/src/commands/doctor.mjs +4 -4
- package/src/commands/git-credential.mjs +2 -2
- package/src/commands/go-live.mjs +9 -5
- package/src/commands/grants.mjs +7 -5
- package/src/commands/hotfix.mjs +1 -1
- package/src/commands/ideas.mjs +2 -2
- package/src/commands/link.mjs +2 -2
- package/src/commands/login.mjs +5 -6
- package/src/commands/pr.mjs +33 -19
- package/src/commands/preview-build.mjs +6 -6
- package/src/commands/preview-doctor.mjs +225 -0
- package/src/commands/preview-retry-evidence.mjs +156 -0
- package/src/commands/preview.mjs +19 -3
- package/src/commands/revert.mjs +5 -5
- package/src/commands/rollback.mjs +18 -16
- package/src/commands/ship.mjs +29 -12
- package/src/commands/start.mjs +61 -51
- package/src/commands/submit.mjs +360 -50
- package/src/commands/sync.mjs +2 -2
- package/src/commands/validate.mjs +4 -3
- package/src/commands/whoami.mjs +1 -1
- package/src/dev-heartbeat.mjs +3 -2
- package/src/dev-logs.mjs +2 -2
- package/src/errors.mjs +11 -4
- package/src/git-credential.mjs +94 -21
- package/src/last-tenant.mjs +1 -1
- package/src/mcp.mjs +6 -1
- package/src/merge-doctor-report.mjs +208 -0
- package/src/oauth.mjs +18 -14
- package/src/obstacle-beacon.cjs +2 -2
- package/src/obstacle.mjs +1 -1
- package/src/plan.mjs +16 -21
- package/src/sample.mjs +4 -4
- package/src/validate.mjs +135 -15
- package/src/vendor/private-apps-devkit.mjs +3 -3
- package/src/viewer-session.mjs +118 -0
- package/template/private-app/README.md +12 -6
- package/src/commands/retire.mjs +0 -203
package/src/commands/submit.mjs
CHANGED
|
@@ -14,9 +14,10 @@
|
|
|
14
14
|
*
|
|
15
15
|
* This is submit-for-PREVIEW, not ship-to-live (`change_accept`/`candidate_accept` /
|
|
16
16
|
* a future `tot ship` is the separate ship gate — this command is submit-only, never
|
|
17
|
-
* accept/reject). Step 2b
|
|
18
|
-
* version-control not configured, preview-access capability)
|
|
19
|
-
*
|
|
17
|
+
* accept/reject). Step 2b remains best-effort for compatibility failures (older MCP,
|
|
18
|
+
* version-control not configured, preview-access capability), but an attribution
|
|
19
|
+
* refusal is REQUIRED and exits non-zero with identity-recovery guidance. The preview
|
|
20
|
+
* push may already have landed, but no unaudited PR is created. Step 3 calls
|
|
20
21
|
* the MCP `preview_status` read-back: given the commit just pushed it returns
|
|
21
22
|
* { status, reconcile, compliance, previewUrl } and we poll it while reconcile is
|
|
22
23
|
* pending. If that tool isn't present (older MCP) the command still validates +
|
|
@@ -47,6 +48,10 @@ import { setTimeout as delay } from "node:timers/promises";
|
|
|
47
48
|
import { createMcpClient } from "../mcp.mjs";
|
|
48
49
|
import { establishSession, AuthUnavailableError } from "../auth.mjs";
|
|
49
50
|
import { checkoutTenant } from "./clone.mjs";
|
|
51
|
+
// Reuse the operator side's `candidate_refresh` result normaliser (unit c3 — the
|
|
52
|
+
// born-rebased submit rebuilds a candidate onto the current base with the SAME
|
|
53
|
+
// engine `tot accept --refresh` uses, so the two read its result identically).
|
|
54
|
+
import { normalizeRefreshResult } from "./accept.mjs";
|
|
50
55
|
import { validateTenant, ERROR } from "../validate.mjs";
|
|
51
56
|
import { openBrowser } from "../open.mjs";
|
|
52
57
|
import { startProgress } from "../progress.mjs";
|
|
@@ -92,6 +97,8 @@ export function candidateRefFor(changeId) {
|
|
|
92
97
|
* value). Fire-and-forget best-effort: a silent no-op without a hosted-bridge
|
|
93
98
|
* credential, never awaited, never throws, never alters the command. `errorClass` is
|
|
94
99
|
* a low-cardinality class (never a raw git stderr, which can carry a token/path).
|
|
100
|
+
* @param {string} op @param {boolean} ok
|
|
101
|
+
* @param {{ command?: string, durationMs?: number, errorClass?: string }} [opts]
|
|
95
102
|
*/
|
|
96
103
|
function emitGitOp(op, ok, { command = "submit", durationMs, errorClass } = {}) {
|
|
97
104
|
void emitActivity({
|
|
@@ -105,7 +112,8 @@ export function parseArgs(argv) {
|
|
|
105
112
|
// `ref: null` — an explicit `--ref` always wins; otherwise the push target is
|
|
106
113
|
// derived per-run as YOUR OWN isolated candidate ref (resolvePushRef, below),
|
|
107
114
|
// never a fixed shared default.
|
|
108
|
-
|
|
115
|
+
/** @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 }} */
|
|
116
|
+
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 };
|
|
109
117
|
for (let i = 0; i < argv.length; i++) {
|
|
110
118
|
const t = argv[i];
|
|
111
119
|
if (t === "--mcp") a.mcp = argv[++i];
|
|
@@ -114,6 +122,7 @@ export function parseArgs(argv) {
|
|
|
114
122
|
else if (t === "-m" || t === "--message") a.message = argv[++i];
|
|
115
123
|
else if (t === "--summary") a.summary = argv[++i];
|
|
116
124
|
else if (t === "--summary-file") a.summaryFile = argv[++i];
|
|
125
|
+
else if (t === "--strategy") a.strategy = argv[++i];
|
|
117
126
|
else if (t === "--json") a.json = true;
|
|
118
127
|
else if (t === "--skip-validate") a.skipValidate = true;
|
|
119
128
|
else if (t === "--skip-freshness") a.skipFreshness = true;
|
|
@@ -121,7 +130,7 @@ export function parseArgs(argv) {
|
|
|
121
130
|
else if (t === "--no-wait") a.noWait = true;
|
|
122
131
|
else if (t === "--watch") a.watch = true;
|
|
123
132
|
else if (t === "--no-open") a.noOpen = true;
|
|
124
|
-
else if (t === "--
|
|
133
|
+
else if (t === "--fork-candidate") a.forkCandidate = true;
|
|
125
134
|
else if (t === "--help" || t === "-h") a.help = true;
|
|
126
135
|
}
|
|
127
136
|
return a;
|
|
@@ -137,11 +146,20 @@ export function renderUsage(verb = "preview") {
|
|
|
137
146
|
return `tot ${verb} — submit your store for preview
|
|
138
147
|
|
|
139
148
|
tot ${verb} validate → push the preview ref → stream the result
|
|
140
|
-
tot ${verb} --
|
|
149
|
+
tot ${verb} --fork-candidate open a SECOND, independently-tracked candidate (a
|
|
150
|
+
parallel dev path). Rarely needed — a fresh git
|
|
151
|
+
branch already gets its own candidate automatically
|
|
152
|
+
(git checkout is the PR switcher); use this only to
|
|
153
|
+
run two candidates from ONE branch.
|
|
141
154
|
tot ${verb} --watch stay attached through reconcile + compliance + accept (long-poll)
|
|
142
155
|
tot ${verb} --skip-validate push without the local lint (not recommended)
|
|
143
|
-
tot ${verb} --skip-freshness skip the stale-base check
|
|
144
|
-
candidate rooted in an
|
|
156
|
+
tot ${verb} --skip-freshness skip the stale-base check AND the born-rebased rebuild
|
|
157
|
+
(not recommended — may build a candidate rooted in an
|
|
158
|
+
already-superseded base)
|
|
159
|
+
tot ${verb} --strategy <s> how the born-rebased rebuild resolves a file changed on
|
|
160
|
+
BOTH sides when the base has moved: "merge" (real 3-way
|
|
161
|
+
merge, surfaces a resolve card on a genuine overlap — the
|
|
162
|
+
default), "ours" (keep yours), "theirs" (keep the store's)
|
|
145
163
|
tot ${verb} --no-commit don't auto-commit a dirty tree — preview only what's already committed
|
|
146
164
|
tot ${verb} --ref <name> push ref (default: your own isolated candidate ref — see \`tot pr\`)
|
|
147
165
|
tot ${verb} -m "<title>" one-line summary of what changed (the approver sees this)
|
|
@@ -159,13 +177,14 @@ export function renderUsage(verb = "preview") {
|
|
|
159
177
|
tot ${verb} --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
|
|
160
178
|
|
|
161
179
|
By default a re-run UPDATES your open candidate PR (like pushing more commits
|
|
162
|
-
to a GitHub PR), rather than opening a new one each time.
|
|
163
|
-
|
|
164
|
-
open candidates with \`tot pr\` (list / view / close). If your candidate was already
|
|
180
|
+
to a GitHub PR), rather than opening a new one each time. Manage your open
|
|
181
|
+
candidates with \`tot pr\` (list / view / close). If your candidate was already
|
|
165
182
|
merged or closed, a re-run automatically opens a fresh one.
|
|
166
183
|
|
|
167
|
-
|
|
168
|
-
|
|
184
|
+
Starting a separate change? \`git checkout -b <branch>\` — a fresh branch gets its
|
|
185
|
+
own candidate automatically, and \`git checkout\` back and forth is how you switch
|
|
186
|
+
between them. --fork-candidate is an escape hatch for the rarer case of wanting a
|
|
187
|
+
SECOND candidate off the SAME branch; reach for a branch first.
|
|
169
188
|
|
|
170
189
|
Once a preview reconciles cleanly, \`tot ship\` promotes it live.
|
|
171
190
|
|
|
@@ -347,7 +366,7 @@ export const FRESHNESS_BASE_BRANCH = "preview";
|
|
|
347
366
|
* Detect a STALE local view of the base branch before minting a candidate —
|
|
348
367
|
* the live-repeat incident this guards against: the checkout's `origin/preview`
|
|
349
368
|
* tracking ref was stale (recorded before a just-merged PR moved it), so a
|
|
350
|
-
* fresh `tot preview --
|
|
369
|
+
* fresh `tot preview --fork-candidate` built a candidate rooted in the OLD tip and got an
|
|
351
370
|
* instant, entirely avoidable "not mergeable" the moment it was compared
|
|
352
371
|
* against the real, already-advanced `preview`.
|
|
353
372
|
*
|
|
@@ -383,6 +402,166 @@ export function detectStaleBase(git, branch) {
|
|
|
383
402
|
return remoteSha;
|
|
384
403
|
}
|
|
385
404
|
|
|
405
|
+
/**
|
|
406
|
+
* How many commits the base branch has advanced since this candidate forked off it
|
|
407
|
+
* (unit c2 — the shift-left base-drift warning). Conflicts in this loop are BASE
|
|
408
|
+
* DRIFT: a candidate branches off `<base>`, sits while OTHER candidates advance
|
|
409
|
+
* `<base>`, then settles mergeable=false at Accept time. This measures that drift
|
|
410
|
+
* cheaply and LOCALLY — the merge-base (fork point) of HEAD and the base's
|
|
411
|
+
* remote-tracking tip, then how many commits separate that fork point from the tip.
|
|
412
|
+
*
|
|
413
|
+
* PURE LOCAL git — reads `refs/remotes/origin/<branch>` (the last-fetched tip),
|
|
414
|
+
* never the network; it complements detectStaleBase (which confirms that tracking
|
|
415
|
+
* ref is itself current). Returns 0 — never a false warning — when there's nothing
|
|
416
|
+
* to compare against or the drift can't be positively determined: no base tracking
|
|
417
|
+
* ref yet, unrelated histories / no merge-base, HEAD already contains the tip, or
|
|
418
|
+
* any git failure. Non-blocking by contract: the caller warns on a positive count
|
|
419
|
+
* but always proceeds. Pure git I/O via the injected runner — unit-tested.
|
|
420
|
+
* @param {(cargs:string[])=>string} git
|
|
421
|
+
* @param {string} branch
|
|
422
|
+
* @returns {number}
|
|
423
|
+
*/
|
|
424
|
+
export function baseCommitsBehind(git, branch) {
|
|
425
|
+
const baseTip = `refs/remotes/origin/${branch}`;
|
|
426
|
+
let tip = "";
|
|
427
|
+
try {
|
|
428
|
+
tip = git(["rev-parse", "-q", "--verify", baseTip]).trim();
|
|
429
|
+
} catch {
|
|
430
|
+
return 0; // no local tracking ref for the base — nothing to compare against
|
|
431
|
+
}
|
|
432
|
+
if (!tip) return 0;
|
|
433
|
+
let mergeBase = "";
|
|
434
|
+
try {
|
|
435
|
+
mergeBase = git(["merge-base", "HEAD", baseTip]).trim();
|
|
436
|
+
} catch {
|
|
437
|
+
return 0; // unrelated histories / no HEAD — nothing meaningful to count
|
|
438
|
+
}
|
|
439
|
+
if (!mergeBase || mergeBase === tip) return 0; // HEAD already contains the base tip
|
|
440
|
+
try {
|
|
441
|
+
const n = parseInt(git(["rev-list", "--count", `${mergeBase}..${baseTip}`]).trim(), 10);
|
|
442
|
+
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
443
|
+
} catch {
|
|
444
|
+
return 0;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Resolve the git ref to diff HEAD against for the candidate's file patch (and its
|
|
450
|
+
* summary). The candidate PR is `preview + your file changes`, and `candidate_open`
|
|
451
|
+
* SERVER-CUTS the candidate branch back to the current preview tip and re-applies the
|
|
452
|
+
* patch — so the patch MUST be the FULL delta of your branch vs its fork point off
|
|
453
|
+
* `preview`, never "what changed since my last candidate push". Diffing against your
|
|
454
|
+
* own candidate tracking ref is what silently produced an empty patch (and the wrong
|
|
455
|
+
* "no file changes" skip, so no PR opened) after a push that landed but failed to open
|
|
456
|
+
* its PR: that ref already equals HEAD, so the since-last-push delta is empty even
|
|
457
|
+
* though the change vs preview is 19 files. Prefer the merge-base (fork point) with
|
|
458
|
+
* `origin/<baseBranch>`; fall back to the candidate tracking ref, then HEAD~1, then ""
|
|
459
|
+
* (single-commit `git show`) when no base ref resolves. Pure git I/O via the injected
|
|
460
|
+
* runner — unit-tested.
|
|
461
|
+
* @param {(cargs:string[])=>string} git
|
|
462
|
+
* @param {string} ref the candidate ref (e.g. "candidate/local-abc")
|
|
463
|
+
* @param {string} [baseBranch] the preview base branch (default FRESHNESS_BASE_BRANCH)
|
|
464
|
+
* @returns {string}
|
|
465
|
+
*/
|
|
466
|
+
export function resolvePatchBase(git, ref, baseBranch = FRESHNESS_BASE_BRANCH) {
|
|
467
|
+
const verify = (r) => {
|
|
468
|
+
try { return git(["rev-parse", "--verify", "--quiet", r]).trim(); } catch { return ""; }
|
|
469
|
+
};
|
|
470
|
+
const previewRef = `refs/remotes/origin/${baseBranch}`;
|
|
471
|
+
if (verify(previewRef)) {
|
|
472
|
+
try {
|
|
473
|
+
const forkPoint = git(["merge-base", "HEAD", previewRef]).trim();
|
|
474
|
+
if (forkPoint) return forkPoint;
|
|
475
|
+
} catch { /* unrelated histories — fall through */ }
|
|
476
|
+
}
|
|
477
|
+
const trackingRef = `refs/remotes/origin/${ref}`;
|
|
478
|
+
if (verify(trackingRef)) return trackingRef;
|
|
479
|
+
if (verify("HEAD~1")) return "HEAD~1";
|
|
480
|
+
return "";
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// ─── born-rebased at submit (unit c3 — shift-left prevention #2) ─────────────────
|
|
484
|
+
|
|
485
|
+
/** The three strategies the born-rebased rebuild accepts, mirroring `tot accept
|
|
486
|
+
* --refresh` (accept.mjs) and the admin one-click resolver: `merge` (real 3-way,
|
|
487
|
+
* refuses on a genuine overlap), `ours` (keep yours), `theirs` (keep the store's).
|
|
488
|
+
* `merge` is the DEFAULT — auto-rebuild on pure drift, name the conflict on a real
|
|
489
|
+
* same-line overlap, never silently clobber a side. */
|
|
490
|
+
export const BORN_REBASED_STRATEGIES = ["ours", "theirs", "merge"];
|
|
491
|
+
export const DEFAULT_BORN_REBASED_STRATEGY = "merge";
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Run `candidate_refresh` (the born-rebased rebuild, unit c3) over the ALREADY
|
|
495
|
+
* established MCP client — the candidate was just opened by candidate_open (step 2b)
|
|
496
|
+
* and the tenant scope is already bound, so this reuses that session rather than
|
|
497
|
+
* establishing its own (accept.mjs's runRefresh is the operator entry that does the
|
|
498
|
+
* sign-in; here the submit flow already holds the session). Rebuilds the candidate
|
|
499
|
+
* from the CURRENT base tip and re-applies its file changes under `strategy`. Returns
|
|
500
|
+
* the normalized result (status:"committed" is the only success). Best-effort: any
|
|
501
|
+
* throw — an owner-gated denial (candidate_refresh is app-owner gated), an older MCP
|
|
502
|
+
* without the tool, a transient failure — normalizes to a non-ok error result the
|
|
503
|
+
* caller submits-as-is on, NEVER blocking the push that already landed.
|
|
504
|
+
* @param {{callTool:Function}} client
|
|
505
|
+
* @param {{ repo: string, changeId: string, strategy: string }} opts
|
|
506
|
+
* @returns {Promise<ReturnType<typeof normalizeRefreshResult>>}
|
|
507
|
+
*/
|
|
508
|
+
export async function runBornRebased(client, { repo, changeId, strategy }) {
|
|
509
|
+
try {
|
|
510
|
+
const raw = await client.callTool("candidate_refresh", { repo, changeId, strategy });
|
|
511
|
+
return normalizeRefreshResult(raw);
|
|
512
|
+
} catch (e) {
|
|
513
|
+
return { ...normalizeRefreshResult(null), status: "error", message: String(e?.message || e) };
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* The born-rebased SUCCESS lines (unit c3) — printed when the candidate rebuilt
|
|
519
|
+
* cleanly onto the current base tip, so it enters the queue already mergeable rather
|
|
520
|
+
* than settling not-mergeable at Accept time. `behind` is the base-drift count that
|
|
521
|
+
* triggered the rebuild. Pure — unit-tested.
|
|
522
|
+
* @param {{ behind?: number, strategy?: string, refreshedFiles?: string[] }} input
|
|
523
|
+
* @returns {string[]}
|
|
524
|
+
*/
|
|
525
|
+
export function formatBornRebasedSuccess({ behind = 0, strategy = DEFAULT_BORN_REBASED_STRATEGY, refreshedFiles = [] } = {}) {
|
|
526
|
+
const drift = behind > 0 ? `${behind} commit${behind === 1 ? "" : "s"}` : "since you forked";
|
|
527
|
+
const lines = [
|
|
528
|
+
`\n ✓ rebuilt fresh on the current store (base had advanced ${drift}, strategy ${strategy}) — your candidate enters the queue already mergeable.`,
|
|
529
|
+
];
|
|
530
|
+
if (refreshedFiles.length) lines.push(` reapplied: ${refreshedFiles.join(", ")}`);
|
|
531
|
+
return lines;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* The born-rebased RESOLVE CARD (unit c3) — printed when the default `merge` rebuild
|
|
536
|
+
* hits a GENUINE same-line overlap with the base (not mere drift): the biggest single
|
|
537
|
+
* shift-left win is surfacing that conflict HERE, at submit, instead of letting it
|
|
538
|
+
* land as a stuck queue row someone discovers at Accept time. Names the diverged files
|
|
539
|
+
* and offers the two one-command resolutions (keep-mine / keep-store's) — `merge` is
|
|
540
|
+
* the default that just failed, so it isn't re-offered. Matches how the rest of this
|
|
541
|
+
* file reports next-steps (a `✗` headline + concrete `tot <verb> …` commands), never a
|
|
542
|
+
* raw git rebase instruction. Pure — unit-tested. `verb` brands the copy with whatever
|
|
543
|
+
* the developer typed.
|
|
544
|
+
* @param {{ unresolved?: string[] }} rr
|
|
545
|
+
* @param {string} [verb]
|
|
546
|
+
* @returns {string[]}
|
|
547
|
+
*/
|
|
548
|
+
export function formatBornRebasedConflict({ unresolved = [] } = {}, verb = "preview") {
|
|
549
|
+
const lines = [
|
|
550
|
+
`\n ✗ your change conflicts with the current store on the same lines — it can't be auto-rebased.`,
|
|
551
|
+
];
|
|
552
|
+
if (unresolved.length) {
|
|
553
|
+
lines.push(` These files changed on both sides since you forked and need your call:`);
|
|
554
|
+
for (const f of unresolved) lines.push(` - ${f}`);
|
|
555
|
+
}
|
|
556
|
+
lines.push(
|
|
557
|
+
` Resolve it in one command — choose which side wins on those files:`,
|
|
558
|
+
` tot ${verb} --strategy=ours keep YOURS on any clash`,
|
|
559
|
+
` tot ${verb} --strategy=theirs keep the STORE's on any clash`,
|
|
560
|
+
` Your push is in; the candidate is submitted but stays not-mergeable until you resolve it.`,
|
|
561
|
+
);
|
|
562
|
+
return lines;
|
|
563
|
+
}
|
|
564
|
+
|
|
386
565
|
// ─── auto-commit the known content trees (unit u2) ───────────────────────────────
|
|
387
566
|
|
|
388
567
|
/**
|
|
@@ -599,9 +778,10 @@ export function parseNameStatus(text) {
|
|
|
599
778
|
* @returns {Array<{path: string, content?: string, contentEncoding?: "base64", delete?: true}>}
|
|
600
779
|
*/
|
|
601
780
|
export function buildFilePatch(entries, readBlob) {
|
|
781
|
+
/** @type {Array<{ path: string, content?: string, contentEncoding?: "base64", delete?: true }>} */
|
|
602
782
|
const patch = [];
|
|
603
783
|
for (const e of entries) {
|
|
604
|
-
if (e.status === "R") patch.push({ path: e.from, delete: true });
|
|
784
|
+
if (e.status === "R") patch.push({ path: /** @type {string} */ (e.from), delete: true });
|
|
605
785
|
if (e.status === "D") {
|
|
606
786
|
patch.push({ path: e.path, delete: true });
|
|
607
787
|
continue;
|
|
@@ -693,7 +873,7 @@ export function tagFromRepoName(repoName, tenant) {
|
|
|
693
873
|
*
|
|
694
874
|
* @param {(cargs:string[])=>string} git throwing git runner (execFileSync-backed)
|
|
695
875
|
* @param {() => Promise<string|null>} mintRemote mints a fresh authed gitRemote (null when unavailable)
|
|
696
|
-
* @param {{ ref
|
|
876
|
+
* @param {{ ref?: string }} [opts]
|
|
697
877
|
* @returns {Promise<{ out: string }>} resolves on a successful push; throws (git's error) otherwise
|
|
698
878
|
*/
|
|
699
879
|
export async function pushPreviewRef(git, mintRemote, { ref } = {}) {
|
|
@@ -779,22 +959,23 @@ export function actorKeyFor(session) {
|
|
|
779
959
|
* Which candidate this submit lands on (gh-pr-like) — decided UP FRONT, before any
|
|
780
960
|
* network call, because it also determines the isolated git ref we push to
|
|
781
961
|
* (resolvePushRef, below): a re-submit updates the SAME candidate/ref by default;
|
|
782
|
-
* `--
|
|
783
|
-
*
|
|
784
|
-
* otherwise
|
|
785
|
-
*
|
|
962
|
+
* `--fork-candidate` forks a fresh one.
|
|
963
|
+
* forkCandidate → fork a FRESH candidate id;
|
|
964
|
+
* otherwise → the remembered active candidate (from a prior --fork-candidate /
|
|
965
|
+
* terminal roll), else the STABLE per-dev-per-tenant(-per-branch)
|
|
966
|
+
* default.
|
|
786
967
|
* `persist` reports whether the choice diverges from the stable default, so the
|
|
787
968
|
* caller knows whether to remember it as the new active pointer. `mint` is
|
|
788
969
|
* injected (defaults to mintFreshChangeId) so this is pure/deterministic in tests.
|
|
789
970
|
* Pure — unit-tested.
|
|
790
971
|
* @param {{ tenant: string, actorKey: string, branch?: string|null, active?: string|null,
|
|
791
|
-
*
|
|
972
|
+
* forkCandidate?: boolean, mint?: (baseId: string) => string }} opts
|
|
792
973
|
* @returns {{ changeId: string, stableId: string, persist: boolean }}
|
|
793
974
|
*/
|
|
794
|
-
export function chooseChangeId({ tenant, actorKey, branch = null, active = null,
|
|
975
|
+
export function chooseChangeId({ tenant, actorKey, branch = null, active = null, forkCandidate = false, mint = mintFreshChangeId }) {
|
|
795
976
|
const stableId = deriveChangeId(tenant, actorKey, branch);
|
|
796
|
-
const changeId =
|
|
797
|
-
const persist =
|
|
977
|
+
const changeId = forkCandidate ? mint(stableId) : (active || stableId);
|
|
978
|
+
const persist = forkCandidate || (!!active && active !== stableId);
|
|
798
979
|
return { changeId, stableId, persist };
|
|
799
980
|
}
|
|
800
981
|
|
|
@@ -847,7 +1028,7 @@ export async function candidateStateFor(client, { repo, changeId }) {
|
|
|
847
1028
|
export async function resolveActivePointer(client, { repo, active }) {
|
|
848
1029
|
if (!active || !repo) return { active };
|
|
849
1030
|
const state = await candidateStateFor(client, { repo, changeId: active });
|
|
850
|
-
if (isTerminalCandidateState(state)) return { active: null, dropped: { changeId: active, state } };
|
|
1031
|
+
if (isTerminalCandidateState(state)) return { active: null, dropped: { changeId: active, state: /** @type {string} */ (state) } };
|
|
851
1032
|
return { active };
|
|
852
1033
|
}
|
|
853
1034
|
|
|
@@ -866,6 +1047,18 @@ export function resolvePushRef({ ref, changeId }) {
|
|
|
866
1047
|
return ref || candidateRefFor(changeId);
|
|
867
1048
|
}
|
|
868
1049
|
|
|
1050
|
+
const REQUIRED_ATTRIBUTION_REFUSALS = new Set([
|
|
1051
|
+
"audit_identity_unavailable",
|
|
1052
|
+
"audit_unavailable",
|
|
1053
|
+
]);
|
|
1054
|
+
|
|
1055
|
+
class CandidateAttributionError extends Error {
|
|
1056
|
+
constructor(message) {
|
|
1057
|
+
super(message);
|
|
1058
|
+
this.name = "CandidateAttributionError";
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
|
|
869
1062
|
/**
|
|
870
1063
|
* Open/update the PR-backed candidate for this submit (g1b `candidate_open`,
|
|
871
1064
|
* unit c1 — the local-dev-loop half of the "PR-Backed Hosted Review Loop"
|
|
@@ -874,8 +1067,8 @@ export function resolvePushRef({ ref, changeId }) {
|
|
|
874
1067
|
* title/body as the PR title/description, and prints the resulting
|
|
875
1068
|
* changeId/PR number/URL. Best-effort: any failure (no repo could be derived,
|
|
876
1069
|
* older MCP, version control not configured, preview-access capability, …) is
|
|
877
|
-
* reported and swallowed —
|
|
878
|
-
*
|
|
1070
|
+
* reported and swallowed — except an audit/identity refusal, which is required
|
|
1071
|
+
* and propagates so the command exits non-zero with the MCP's recovery guidance.
|
|
879
1072
|
* @param {ReturnType<import("../mcp.mjs").createMcpClient>} client
|
|
880
1073
|
* @param {{ repo: string|null, changeId: string, changeSummary: {title:string, body:string[]},
|
|
881
1074
|
* patchEntries: {status:string, path:string, from?:string}[], readBlob: (path:string)=>Buffer,
|
|
@@ -903,9 +1096,15 @@ export async function submitCandidate(client, { repo, changeId, changeSummary, p
|
|
|
903
1096
|
body: changeSummary.body.length ? changeSummary.body.join("\n").slice(0, 4000) : undefined,
|
|
904
1097
|
patch,
|
|
905
1098
|
});
|
|
1099
|
+
if (REQUIRED_ATTRIBUTION_REFUSALS.has(result?.status)) {
|
|
1100
|
+
throw new CandidateAttributionError(
|
|
1101
|
+
result?.message || "Candidate not created: verified actor attribution is required.",
|
|
1102
|
+
);
|
|
1103
|
+
}
|
|
906
1104
|
reportCandidate(result, changeId, { quiet });
|
|
907
1105
|
return result;
|
|
908
1106
|
} catch (e) {
|
|
1107
|
+
if (e instanceof CandidateAttributionError) throw e;
|
|
909
1108
|
if (!quiet) {
|
|
910
1109
|
console.log(` ~ couldn't open/update the PR-backed candidate: ${String(e?.message || e)}`);
|
|
911
1110
|
console.log(` (best-effort — your push is still in; this doesn't block reconcile.)`);
|
|
@@ -977,7 +1176,7 @@ export function buildJsonResult({ ok, ref = null, commit = null, changeId = null
|
|
|
977
1176
|
shipped: status?.shipped ?? null,
|
|
978
1177
|
dispatched: status?.dispatched ?? null,
|
|
979
1178
|
notDispatched: status?.notDispatched ?? false,
|
|
980
|
-
forwardFailed: status?.forwardFailed ?? false,
|
|
1179
|
+
forwardFailed: /** @type {any} */ (status)?.forwardFailed ?? false,
|
|
981
1180
|
delivery: status?.delivery ?? null,
|
|
982
1181
|
previewPrUrl,
|
|
983
1182
|
...(error ? { error } : {}),
|
|
@@ -1015,6 +1214,15 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1015
1214
|
emitJson(args, buildJsonResult({ ok: false, error: msg }));
|
|
1016
1215
|
return 2;
|
|
1017
1216
|
}
|
|
1217
|
+
// --strategy (c3 born-rebased rebuild) — validate whenever given; defaults to
|
|
1218
|
+
// "merge" (auto-rebuild on drift, surface a resolve card on a genuine overlap).
|
|
1219
|
+
if (args.strategy != null && !BORN_REBASED_STRATEGIES.includes(args.strategy)) {
|
|
1220
|
+
const msg = `unknown --strategy "${args.strategy}"`;
|
|
1221
|
+
console.error(fail(msg, `use one of: ${BORN_REBASED_STRATEGIES.join(", ")} (default "${DEFAULT_BORN_REBASED_STRATEGY}")`));
|
|
1222
|
+
emitJson(args, buildJsonResult({ ok: false, error: msg }));
|
|
1223
|
+
return 2;
|
|
1224
|
+
}
|
|
1225
|
+
const bornRebasedStrategy = args.strategy || DEFAULT_BORN_REBASED_STRATEGY;
|
|
1018
1226
|
if (args.summaryFile) {
|
|
1019
1227
|
let raw;
|
|
1020
1228
|
try {
|
|
@@ -1050,6 +1258,12 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1050
1258
|
/* best-effort — see above */
|
|
1051
1259
|
}
|
|
1052
1260
|
|
|
1261
|
+
// How far the base has drifted since this candidate forked (unit c2), hoisted to the
|
|
1262
|
+
// whole flow: it drives BOTH the c2 pre-push warning (below) AND the c3 born-rebased
|
|
1263
|
+
// rebuild (after candidate_open). Stays 0 under --skip-freshness, so that flag opts
|
|
1264
|
+
// out of the rebuild too — consistent with opting out of the warning.
|
|
1265
|
+
let baseDrift = 0;
|
|
1266
|
+
|
|
1053
1267
|
// Freshness preflight (unit u16) — BEFORE minting anything: is the checkout's
|
|
1054
1268
|
// cached view of the base branch already behind the store? A candidate built
|
|
1055
1269
|
// on a stale base is an instant, avoidable "not mergeable" the moment the
|
|
@@ -1070,6 +1284,24 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1070
1284
|
emitJson(args, buildJsonResult({ ok: false, error: msg }));
|
|
1071
1285
|
return 1;
|
|
1072
1286
|
}
|
|
1287
|
+
|
|
1288
|
+
// Base-drift warning (unit c2) — a candidate rooted on a fork point the base has
|
|
1289
|
+
// since moved past is what settles mergeable=false at Accept time; catch it early,
|
|
1290
|
+
// right here, with a pure-LOCAL git read (no network — uses the base's
|
|
1291
|
+
// remote-tracking tip, which the stale-base preflight just above confirmed is
|
|
1292
|
+
// current). NON-BLOCKING: warn and submit anyway, so a work-in-progress preview
|
|
1293
|
+
// is never refused over base drift. Shares --skip-freshness with the preflight.
|
|
1294
|
+
try {
|
|
1295
|
+
baseDrift = baseCommitsBehind(git, FRESHNESS_BASE_BRANCH);
|
|
1296
|
+
} catch {
|
|
1297
|
+
baseDrift = 0; // never let the warning's OWN failure disturb the submit
|
|
1298
|
+
}
|
|
1299
|
+
if (baseDrift > 0) {
|
|
1300
|
+
console.error(`\n⚠ base is ${baseDrift} commit${baseDrift === 1 ? "" : "s"} behind — rebase before submit`);
|
|
1301
|
+
console.error(
|
|
1302
|
+
` (\`${FRESHNESS_BASE_BRANCH}\` has advanced since your branch forked off it — \`tot sync\` to rebase, or --skip-freshness to silence)`,
|
|
1303
|
+
);
|
|
1304
|
+
}
|
|
1073
1305
|
}
|
|
1074
1306
|
|
|
1075
1307
|
|
|
@@ -1177,12 +1409,12 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1177
1409
|
};
|
|
1178
1410
|
function buildSummaryAndPatch(ref) {
|
|
1179
1411
|
const headSubject = gitSafe(["log", "-1", "--format=%s"]).trim();
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1412
|
+
// Diff against the fork point off `preview` — the FULL branch delta — so the patch
|
|
1413
|
+
// is complete for candidate_open's server-cut (which resets the branch to preview
|
|
1414
|
+
// and re-applies this patch). NOT the candidate's own tracking ref: after a push
|
|
1415
|
+
// that landed but failed to open its PR, that ref equals HEAD → empty patch →
|
|
1416
|
+
// wrong "no file changes" → no PR. See resolvePatchBase.
|
|
1417
|
+
const base = resolvePatchBase(gitSafe, ref);
|
|
1186
1418
|
const statusCmd = base ? ["diff", "--name-status", `${base}..HEAD`] : ["show", "--name-status", "--format=", "HEAD"];
|
|
1187
1419
|
const patchEntries = parseNameStatus(gitSafe(statusCmd));
|
|
1188
1420
|
const files = patchEntries.map((e) => e.path);
|
|
@@ -1217,7 +1449,7 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1217
1449
|
// still lands if that embedded token is live. actorKeyFor(null) degrades to the
|
|
1218
1450
|
// generic "developer" key — still isolated PER BRANCH (never the shared ref),
|
|
1219
1451
|
// just not per-developer until sign-in succeeds.
|
|
1220
|
-
const { changeId } = chooseChangeId({ tenant, actorKey: actorKeyFor(null), branch, active,
|
|
1452
|
+
const { changeId } = chooseChangeId({ tenant, actorKey: actorKeyFor(null), branch, active, forkCandidate: args.forkCandidate });
|
|
1221
1453
|
const ref = resolvePushRef({ ref: args.ref, changeId });
|
|
1222
1454
|
const { changeSummary } = buildSummaryAndPatch(ref);
|
|
1223
1455
|
console.error(`~ pushing ${short} → ${ref} (origin)`);
|
|
@@ -1260,11 +1492,11 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1260
1492
|
// If it merged/closed we DROP it (and forget it on disk) so chooseChangeId falls
|
|
1261
1493
|
// back to the stable id, rather than force-pushing onto a now-dead candidate branch
|
|
1262
1494
|
// and opening a NEW PR that inherits a guaranteed conflict (the live incident this
|
|
1263
|
-
// guards against). Skipped under --
|
|
1264
|
-
// anyway). Purely diagnostic: a check that errors leaves the pointer untouched.
|
|
1495
|
+
// guards against). Skipped under --fork-candidate (chooseChangeId ignores `active`
|
|
1496
|
+
// there anyway). Purely diagnostic: a check that errors leaves the pointer untouched.
|
|
1265
1497
|
// Needs the tenant scope bound for candidate_status to resolve — idempotent with
|
|
1266
1498
|
// the later client_switch / the fresh-mint checkoutTenant.
|
|
1267
|
-
if (active && repo && !args.
|
|
1499
|
+
if (active && repo && !args.forkCandidate) {
|
|
1268
1500
|
try {
|
|
1269
1501
|
await client.callTool("client_switch", { tenant });
|
|
1270
1502
|
} catch { /* scope bind is best-effort; candidateStateFor tolerates a miss */ }
|
|
@@ -1283,9 +1515,9 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1283
1515
|
// Which candidate (and therefore which isolated ref, b03) this submit targets —
|
|
1284
1516
|
// decided now, with a real session, so the SAME id backs both the raw git push
|
|
1285
1517
|
// (right below) and the PR-backed candidate (step 2b): the two never point at
|
|
1286
|
-
// different branches. See chooseChangeId's doc for the --
|
|
1287
|
-
// rules.
|
|
1288
|
-
let { changeId, stableId, persist } = chooseChangeId({ tenant, actorKey: actorKeyFor(session), branch, active,
|
|
1518
|
+
// different branches. See chooseChangeId's doc for the --fork-candidate /
|
|
1519
|
+
// active-pointer rules.
|
|
1520
|
+
let { changeId, stableId, persist } = chooseChangeId({ tenant, actorKey: actorKeyFor(session), branch, active, forkCandidate: args.forkCandidate });
|
|
1289
1521
|
const ref = resolvePushRef({ ref: args.ref, changeId });
|
|
1290
1522
|
const { changeSummary, patchEntries } = buildSummaryAndPatch(ref);
|
|
1291
1523
|
|
|
@@ -1334,9 +1566,10 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1334
1566
|
// (idempotent — checkoutTenant already switched when the fresh mint succeeded).
|
|
1335
1567
|
await client.callTool("client_switch", { tenant });
|
|
1336
1568
|
|
|
1337
|
-
// 2b. PR-backed candidate (g1b candidate_open, unit c1)
|
|
1338
|
-
//
|
|
1339
|
-
//
|
|
1569
|
+
// 2b. PR-backed candidate (g1b candidate_open, unit c1). Compatibility and
|
|
1570
|
+
// capability failures remain best-effort, but the MCP's audit/identity refusals
|
|
1571
|
+
// propagate and make this command fail: an unaudited PR is never an acceptable
|
|
1572
|
+
// successful submit.
|
|
1340
1573
|
// `changeId`/`stableId`/`persist` were already decided above (they picked the
|
|
1341
1574
|
// push ref too); if the chosen candidate turns out to be merged/closed, roll to
|
|
1342
1575
|
// a fresh one so a re-submit is never wedged on a dead PR. (`repo` was derived
|
|
@@ -1353,6 +1586,61 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1353
1586
|
candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob, quiet: args.json });
|
|
1354
1587
|
}
|
|
1355
1588
|
|
|
1589
|
+
// c3 — BORN-REBASED at submit (shift-left prevention #2). The candidate is open;
|
|
1590
|
+
// if the base has drifted (the SAME signal c2 warned on, pre-push) rebuild it from
|
|
1591
|
+
// the CURRENT base tip via candidate_refresh BEFORE finalizing, so it enters the
|
|
1592
|
+
// queue already fresh instead of settling not-mergeable at Accept time.
|
|
1593
|
+
// • clean rebuild → report it, hand back the fresh candidate's shareable URL,
|
|
1594
|
+
// and finish (the rebuilt PR has a NEW head; polling the old
|
|
1595
|
+
// local `commit` would read as never-dispatched, so we skip
|
|
1596
|
+
// the reconcile poll and say the fresh preview is building);
|
|
1597
|
+
// • genuine overlap (merge_failed) → surface the resolve card right here, then
|
|
1598
|
+
// fall through — candidate_refresh made NO changes on a real
|
|
1599
|
+
// conflict, so the candidate's head still matches `commit`
|
|
1600
|
+
// and the normal reconcile poll below is still valid;
|
|
1601
|
+
// • anything else (owner-gated denial, older MCP, error) → submit as-is.
|
|
1602
|
+
// Best-effort throughout: candidate_refresh is app-owner gated, so an ordinary
|
|
1603
|
+
// invited developer's session may be denied — that degrades to submitting as-is,
|
|
1604
|
+
// never blocking the push that already landed. Gated on --skip-freshness via
|
|
1605
|
+
// baseDrift (0 when skipped).
|
|
1606
|
+
if (repo && baseDrift > 0 && candidate && !isTerminalCandidateState(candidate.state) && changeId) {
|
|
1607
|
+
const rebased = await runBornRebased(client, { repo, changeId, strategy: bornRebasedStrategy });
|
|
1608
|
+
if (rebased.ok) {
|
|
1609
|
+
for (const line of formatBornRebasedSuccess({ behind: baseDrift, strategy: rebased.strategy || bornRebasedStrategy, refreshedFiles: rebased.refreshedFiles })) {
|
|
1610
|
+
if (!args.json) console.log(line);
|
|
1611
|
+
}
|
|
1612
|
+
const freshPr = typeof rebased.prNumber === "number" ? rebased.prNumber : candidate.prNumber;
|
|
1613
|
+
const freshPrUrl = typeof freshPr === "number"
|
|
1614
|
+
? shareablePrUrl(env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL, tenant, freshPr)
|
|
1615
|
+
: null;
|
|
1616
|
+
if (freshPrUrl && !args.json) {
|
|
1617
|
+
console.log(`\n ▸ Your fresh preview will appear at:\n ${freshPrUrl}\n (building on the current store — this link goes live once reconcile completes)`);
|
|
1618
|
+
}
|
|
1619
|
+
// The rebuild keeps the STABLE changeId, so the persisted active pointer stays
|
|
1620
|
+
// valid — record it (best-effort) exactly as the normal open path does below.
|
|
1621
|
+
if (persist && repo) {
|
|
1622
|
+
try {
|
|
1623
|
+
writeActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch, changeId });
|
|
1624
|
+
} catch { /* best-effort local hint */ }
|
|
1625
|
+
}
|
|
1626
|
+
emitJson(args, buildJsonResult({
|
|
1627
|
+
ok: true, ref, commit, changeId,
|
|
1628
|
+
candidate: { ...candidate, prNumber: freshPr ?? candidate.prNumber },
|
|
1629
|
+
previewPrUrl: freshPrUrl,
|
|
1630
|
+
note: `born-rebased on the current base (${rebased.strategy || bornRebasedStrategy})`,
|
|
1631
|
+
}));
|
|
1632
|
+
return 0;
|
|
1633
|
+
}
|
|
1634
|
+
if (rebased.status === "merge_failed") {
|
|
1635
|
+
for (const line of formatBornRebasedConflict({ unresolved: rebased.unresolved }, verb)) {
|
|
1636
|
+
if (!args.json) console.log(line);
|
|
1637
|
+
}
|
|
1638
|
+
// fall through to the normal poll — the candidate is unchanged.
|
|
1639
|
+
} else if (rebased.message && !args.json) {
|
|
1640
|
+
console.log(` ~ couldn't auto-rebase on the current base (${rebased.message}) — submitting as-is.`);
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1356
1644
|
// Immediate shareable URL (Vercel-style: "the URL exists before the build
|
|
1357
1645
|
// does"). A non-terminal candidate with a real PR number means a preview
|
|
1358
1646
|
// WILL be built at a deterministic route — so hand the developer that link
|
|
@@ -1422,6 +1710,12 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1422
1710
|
return status?.status === "failed" ? 1 : 0;
|
|
1423
1711
|
} catch (e) {
|
|
1424
1712
|
progress?.stop();
|
|
1713
|
+
if (e instanceof CandidateAttributionError) {
|
|
1714
|
+
const note = `${e.message} Your preview push is in, but no review PR was created.`;
|
|
1715
|
+
if (!args.json) console.error(fail(note, "follow the identity guidance above, then re-run `tot submit`"));
|
|
1716
|
+
emitJson(args, buildJsonResult({ ok: false, ref, commit, changeId, error: note }));
|
|
1717
|
+
return 1;
|
|
1718
|
+
}
|
|
1425
1719
|
const note = e instanceof AuthUnavailableError
|
|
1426
1720
|
? `sign in to see the reconcile/compliance/preview result — ${e.hint || "developer sign-in pending"}`
|
|
1427
1721
|
: `reconcile is running — the result read-back isn't available yet: ${describeReadbackError(e)}`;
|
|
@@ -1489,16 +1783,28 @@ export function normalizePreviewStatus(r) {
|
|
|
1489
1783
|
* still absent, we stop and return the honest state tagged `notDispatched: true`. If
|
|
1490
1784
|
* a delivery IS seen we keep polling as before (dispatched, just slow), and a plain
|
|
1491
1785
|
* timeout while still pending is tagged `notDispatched` only when a delivery was never
|
|
1492
|
-
* observed. Injectable delay/attempts/waitMs/grace + a `now` clock
|
|
1786
|
+
* observed. Injectable delay/attempts/waitMs/grace + a `now` clock and `sleep`
|
|
1787
|
+
* fn for tests (so the fallback-sleep cadence can be asserted deterministically
|
|
1788
|
+
* off the injected clock instead of real wall-clock elapsed time).
|
|
1493
1789
|
* @param {{callTool:Function}} client
|
|
1494
1790
|
* @param {string} commit
|
|
1495
1791
|
* @param {{ attempts?: number, delayMs?: number, waitMs?: number, untilShipped?: boolean,
|
|
1496
|
-
* notDispatchedGraceMs?: number, now?: () => number,
|
|
1792
|
+
* notDispatchedGraceMs?: number, now?: () => number, sleep?: (ms:number) => Promise<void>,
|
|
1793
|
+
* onTick?: (s:object,i:number)=>void }} [opts]
|
|
1497
1794
|
*/
|
|
1498
1795
|
export async function pollPreviewStatus(
|
|
1499
1796
|
client,
|
|
1500
1797
|
commit,
|
|
1501
|
-
{
|
|
1798
|
+
{
|
|
1799
|
+
attempts = 8,
|
|
1800
|
+
delayMs = 2500,
|
|
1801
|
+
waitMs = delayMs,
|
|
1802
|
+
untilShipped = false,
|
|
1803
|
+
notDispatchedGraceMs = 15_000,
|
|
1804
|
+
now = Date.now,
|
|
1805
|
+
sleep = delay,
|
|
1806
|
+
onTick,
|
|
1807
|
+
} = {},
|
|
1502
1808
|
) {
|
|
1503
1809
|
let last = null;
|
|
1504
1810
|
let everDispatched = false;
|
|
@@ -1533,7 +1839,7 @@ export async function pollPreviewStatus(
|
|
|
1533
1839
|
if (last.status !== "pending" && !stillWatchingForShip) return last;
|
|
1534
1840
|
if (i < attempts - 1) {
|
|
1535
1841
|
const remaining = delayMs - (now() - startedAt);
|
|
1536
|
-
if (remaining > 0) await
|
|
1842
|
+
if (remaining > 0) await sleep(remaining);
|
|
1537
1843
|
}
|
|
1538
1844
|
}
|
|
1539
1845
|
// Budget exhausted. A still-pending result that never saw a delivery is a
|
|
@@ -1593,7 +1899,7 @@ export function shareablePrUrl(base, tenant, prNumber) {
|
|
|
1593
1899
|
* @returns {string}
|
|
1594
1900
|
*/
|
|
1595
1901
|
export function describeReadbackError(e) {
|
|
1596
|
-
const msg = String(e?.message || e || "");
|
|
1902
|
+
const msg = String(/** @type {any} */ (e)?.message || e || "");
|
|
1597
1903
|
const jsonStart = msg.indexOf("{");
|
|
1598
1904
|
if (jsonStart >= 0 && msg.includes("self_repair")) {
|
|
1599
1905
|
try {
|
|
@@ -1649,7 +1955,7 @@ export function formatShareableUrlBlock(s, tenant) {
|
|
|
1649
1955
|
* back / re-submit" lie for the dead-end case: re-submitting cannot help, so we say
|
|
1650
1956
|
* what actually happened and what to do, and never recommend another submit. Pure —
|
|
1651
1957
|
* unit-tested. `verb` brands the copy with whatever the developer typed.
|
|
1652
|
-
* @param {{ commit?: string|null, ref?: string|null }} ctx
|
|
1958
|
+
* @param {{ commit?: string|null, ref?: string|null, noChanges?: boolean }} ctx
|
|
1653
1959
|
* @param {string} tenant @param {string} [verb]
|
|
1654
1960
|
* @returns {string[]}
|
|
1655
1961
|
*/
|
|
@@ -1706,6 +2012,10 @@ export function formatForwardFailedBlock({ commit = null } = {}, tenant) {
|
|
|
1706
2012
|
* --json — automation doesn't want a browser popping up). `commit`/`ref`/`verb`
|
|
1707
2013
|
* feed the honest never-dispatched block.
|
|
1708
2014
|
*/
|
|
2015
|
+
/**
|
|
2016
|
+
* @param {any} s @param {string} tenant
|
|
2017
|
+
* @param {{ open?: boolean, quiet?: boolean, commit?: string|null, ref?: string|null, verb?: string, noChanges?: boolean }} [opts]
|
|
2018
|
+
*/
|
|
1709
2019
|
function reportStatus(s, tenant, { open = true, quiet = false, commit = null, ref = null, verb = "preview", noChanges = false } = {}) {
|
|
1710
2020
|
if (!s || s.status === "unknown") {
|
|
1711
2021
|
if (!quiet) {
|