@tokenoftrust/cli 1.4.0-rc.2 → 1.4.0-rc.20
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 +12 -9
- package/bin/tot.mjs +191 -14
- package/package.json +2 -2
- package/src/activity.mjs +378 -0
- package/src/candidate-state.mjs +137 -0
- package/src/commands/accept.mjs +313 -0
- package/src/commands/branches.mjs +296 -0
- package/src/commands/cleanup.mjs +268 -0
- package/src/commands/clone.mjs +682 -0
- package/src/commands/dev.mjs +414 -84
- package/src/commands/doctor.mjs +4 -3
- package/src/commands/go-live.mjs +482 -0
- package/src/commands/grants.mjs +8 -3
- package/src/commands/hotfix.mjs +428 -0
- package/src/commands/link.mjs +225 -0
- package/src/commands/login.mjs +9 -4
- package/src/commands/pr.mjs +424 -0
- package/src/commands/preview-build.mjs +225 -0
- package/src/commands/preview.mjs +80 -0
- package/src/commands/retire.mjs +203 -0
- package/src/commands/revert.mjs +322 -0
- package/src/commands/rollback.mjs +401 -0
- package/src/commands/ship.mjs +517 -0
- package/src/commands/start.mjs +35 -21
- package/src/commands/submit.mjs +1129 -129
- package/src/commands/sync.mjs +192 -0
- package/src/commands/validate.mjs +2 -2
- package/src/commands/whoami.mjs +6 -2
- package/src/context.mjs +2 -2
- package/src/no-gitea-links.test.mjs +55 -0
- package/src/oauth.mjs +14 -3
- package/src/obstacle-beacon.cjs +1 -1
- package/src/plan.mjs +262 -0
- package/src/sample.mjs +27 -1
- package/src/commands/checkout.mjs +0 -330
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tot accept --tenant <t> --pr <N>` (alias: `tot merge`) — OPERATOR verb: QUEUE a
|
|
3
|
+
* PR's integration into the protected `preview` AGGREGATE. This SUPERSEDES the
|
|
4
|
+
* retired `tot accept` = merge-PR→main semantics (operator-console U5): accepting a
|
|
5
|
+
* change no longer merges it to main — it enqueues it into the tenant's shared
|
|
6
|
+
* `preview` aggregate, where b07's tenant-serialized queue merges it (b06
|
|
7
|
+
* `candidate_accept`, preview-base only), rebuilds the aggregate, and moves the
|
|
8
|
+
* shared preview pointer ONLY when combined evidence is green.
|
|
9
|
+
*
|
|
10
|
+
* DISTINCT from `tot ship`: accept is NOT go-live. It touches NO `main` and NO live
|
|
11
|
+
* channel; a green aggregate is promoted live only by a later `tot ship`. And it is
|
|
12
|
+
* NOT `tot preview build` (which materializes ONE candidate's own preview in
|
|
13
|
+
* isolation) — accept lands the candidate into the SHARED aggregate other reviewers
|
|
14
|
+
* see.
|
|
15
|
+
*
|
|
16
|
+
* TARGET RESOLUTION — `--pr N --tenant t` names the PR; the server resolves it to its
|
|
17
|
+
* candidate from the tenant's ReviewEnvironment index (unit b04 — tenant + PR, NO
|
|
18
|
+
* chg-id). An explicit `--change-id` is still accepted (targets a specific record),
|
|
19
|
+
* but is no longer REQUIRED — the whole point of b08 is that a PR number is enough.
|
|
20
|
+
*
|
|
21
|
+
* TRANSPORT — the honest accept path is `POST /api/changes/integrate` (the ONE call
|
|
22
|
+
* site of b07's `TenantIntegrationQueue.enqueue`). Like `tot ship --pr` (U16) and
|
|
23
|
+
* `tot pr list --tenant` (U17), the CLI reaches it with the OPERATOR-SECRET Bearer
|
|
24
|
+
* transport (`resolveOperatorSecret` + `X-Tot-Owner` + `x-tot-capability`), since the
|
|
25
|
+
* CLI holds no storefront cookie. The response is the honest `IntegrateOutcome` —
|
|
26
|
+
* `queueState` / `runState` / `pointerMoved` / `aggregateSha` / `statusMessage` —
|
|
27
|
+
* which this verb renders VERBATIM, never a bare "merged".
|
|
28
|
+
*
|
|
29
|
+
* HUMAN GATE — integrating into the shared preview is a decision a human makes, so
|
|
30
|
+
* this ALWAYS states the EXACT plan (shared `planForAction`, unit U10: which PR,
|
|
31
|
+
* which tenant, "queue for integration into the preview aggregate — NO merge, NO
|
|
32
|
+
* go-live") and requires an explicit confirm. `--yes` is an explicit affirmative; a
|
|
33
|
+
* non-TTY without `--yes` is refused (mirrors `tot ship`'s non-TTY refusal).
|
|
34
|
+
*
|
|
35
|
+
* Dependency-free (global fetch + the shared plan module).
|
|
36
|
+
*/
|
|
37
|
+
import { fail } from "../errors.mjs";
|
|
38
|
+
import { planForAction, printPlanAndConfirm } from "../plan.mjs";
|
|
39
|
+
// Reuse `tot ship`'s operator-secret precedence verbatim so accept + ship + pr-list
|
|
40
|
+
// speak ONE operator-auth contract, not three.
|
|
41
|
+
import { resolveOperatorSecret } from "./ship.mjs";
|
|
42
|
+
|
|
43
|
+
const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
|
|
44
|
+
|
|
45
|
+
const USAGE = `tot accept — queue a PR's integration into the preview aggregate (alias: tot merge)
|
|
46
|
+
|
|
47
|
+
tot accept --tenant <t> --pr <N>
|
|
48
|
+
tot merge --tenant <t> --pr <N> (same command)
|
|
49
|
+
|
|
50
|
+
Queues the given PR for integration into the tenant's protected \`preview\`
|
|
51
|
+
aggregate: the serialized queue merges it into \`preview\` (preview-base only),
|
|
52
|
+
rebuilds the aggregate, runs combined evidence, and moves the shared preview
|
|
53
|
+
pointer ONLY when green. Accepting does NOT merge to main and does NOT go live —
|
|
54
|
+
a green aggregate is promoted live later by \`tot ship\`.
|
|
55
|
+
|
|
56
|
+
Integrating into the shared preview is a human decision: this ALWAYS prints the
|
|
57
|
+
exact plan and asks for an explicit confirm. There is no default-yes; a non-TTY
|
|
58
|
+
without --yes is refused rather than silently proceeding.
|
|
59
|
+
|
|
60
|
+
Options:
|
|
61
|
+
--tenant <appDomain> Target tenant (e.g. tokenoftrust.com). Defaults to the
|
|
62
|
+
current checkout's tenant when run inside one.
|
|
63
|
+
--pr <N> PR number to integrate. The server resolves it to its
|
|
64
|
+
candidate from the tenant's queue (no --change-id needed).
|
|
65
|
+
--change-id <id> Target a specific change record instead of a PR number.
|
|
66
|
+
--head-sha <sha> Optional expected PR head sha (expectedHeadSha) — an
|
|
67
|
+
optimistic-concurrency guard against a PR that moved.
|
|
68
|
+
--url <origin> storefront origin (default: env TOT_STOREFRONT_URL)
|
|
69
|
+
--secret <s> operator secret (prefer the env vars below)
|
|
70
|
+
--yes, -y Skip the interactive confirm (still an explicit human
|
|
71
|
+
affirmative — there is no default-yes).
|
|
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).`;
|
|
76
|
+
|
|
77
|
+
/** Parse `tot accept` / `tot merge` argv. Pure — unit-testable. */
|
|
78
|
+
export function parseAcceptArgs(argv) {
|
|
79
|
+
const a = {
|
|
80
|
+
tenant: null,
|
|
81
|
+
pr: null,
|
|
82
|
+
changeId: null,
|
|
83
|
+
headSha: null,
|
|
84
|
+
url: null,
|
|
85
|
+
secret: null,
|
|
86
|
+
identity: null,
|
|
87
|
+
yes: false,
|
|
88
|
+
help: false,
|
|
89
|
+
};
|
|
90
|
+
for (let i = 0; i < argv.length; i++) {
|
|
91
|
+
const t = argv[i];
|
|
92
|
+
if (t === "--tenant") a.tenant = argv[++i];
|
|
93
|
+
else if (t === "--pr") a.pr = argv[++i];
|
|
94
|
+
else if (t === "--change-id") a.changeId = argv[++i];
|
|
95
|
+
else if (t === "--head-sha" || t === "--head") a.headSha = argv[++i];
|
|
96
|
+
else if (t === "--url") a.url = argv[++i];
|
|
97
|
+
else if (t === "--secret") a.secret = argv[++i];
|
|
98
|
+
else if (t === "--identity") a.identity = argv[++i];
|
|
99
|
+
else if (t === "--yes" || t === "-y") a.yes = true;
|
|
100
|
+
else if (t === "--help" || t === "-h") a.help = true;
|
|
101
|
+
}
|
|
102
|
+
return a;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Normalise a `POST /api/changes/integrate` body to the honest terminal aggregate
|
|
107
|
+
* fields this verb renders. Reads defensively so a plausible field rename degrades
|
|
108
|
+
* rather than crashes. Pure — unit-tested.
|
|
109
|
+
* @param {any} data
|
|
110
|
+
* @returns {{ ok:boolean, queueState:string|null, runState:string|null,
|
|
111
|
+
* pointerMoved:boolean, aggregateSha:string|null, statusMessage:string|null,
|
|
112
|
+
* reason:string|null, changeId:string|null, prNumber:number|null, error:string|null, raw:any }}
|
|
113
|
+
*/
|
|
114
|
+
export function normalizeIntegrateResponse(data) {
|
|
115
|
+
const o = data && typeof data === "object" ? data : {};
|
|
116
|
+
return {
|
|
117
|
+
ok: o.ok === true,
|
|
118
|
+
queueState: typeof o.queueState === "string" ? o.queueState : null,
|
|
119
|
+
runState: typeof o.runState === "string" ? o.runState : null,
|
|
120
|
+
pointerMoved: o.pointerMoved === true,
|
|
121
|
+
aggregateSha: typeof o.aggregateSha === "string" ? o.aggregateSha : null,
|
|
122
|
+
statusMessage: typeof o.statusMessage === "string" ? o.statusMessage : null,
|
|
123
|
+
reason: typeof o.reason === "string" ? o.reason : null,
|
|
124
|
+
changeId: typeof o.changeId === "string" ? o.changeId : null,
|
|
125
|
+
prNumber: typeof o.prNumber === "number" ? o.prNumber : null,
|
|
126
|
+
error: typeof o.error === "string" ? o.error : null,
|
|
127
|
+
raw: data,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Render the terminal aggregate status in house style — the honest queue/run state,
|
|
133
|
+
* NEVER a bare "merged". Pure given its inputs; returns the process exit code.
|
|
134
|
+
* @param {ReturnType<typeof normalizeIntegrateResponse>} result
|
|
135
|
+
* @param {{ tenant:string, label:string }} ctx
|
|
136
|
+
* @returns {number}
|
|
137
|
+
*/
|
|
138
|
+
export function reportIntegrated(result, { tenant, label }) {
|
|
139
|
+
const state = `${result.queueState ?? "?"}/${result.runState ?? "—"}`;
|
|
140
|
+
if (result.ok) {
|
|
141
|
+
console.log(`\n ✓ queued ${label} into ${tenant}'s preview aggregate — it is GREEN.`);
|
|
142
|
+
console.log(` aggregate: ${state}${result.aggregateSha ? ` (${result.aggregateSha})` : ""}`);
|
|
143
|
+
if (result.pointerMoved) console.log(" the shared preview pointer moved to this aggregate.");
|
|
144
|
+
console.log(" → next: `tot ship` to promote this green aggregate live.");
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
147
|
+
// Honest non-green: the candidate did NOT land in the shippable aggregate.
|
|
148
|
+
console.log(`\n ✗ ${label} did NOT integrate into ${tenant}'s preview aggregate.`);
|
|
149
|
+
console.log(` aggregate: ${state}${result.reason ? ` (${result.reason})` : ""}`);
|
|
150
|
+
if (result.statusMessage) console.log(` why: ${result.statusMessage}`);
|
|
151
|
+
console.log(" → next: fix the candidate (re-`tot preview`), then re-run `tot accept`.");
|
|
152
|
+
return 1;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The accept-means-integrate flow after args are parsed: state the exact plan,
|
|
157
|
+
* confirm, then POST `/api/changes/integrate` (operator-secret transport) and render
|
|
158
|
+
* the honest terminal aggregate status. `fetch`/`confirmPlan` injected so it is
|
|
159
|
+
* unit-tested with no network/TTY.
|
|
160
|
+
*
|
|
161
|
+
* @param {{ tenant:string, pr:number|null, changeId:string|null, headSha:string|null,
|
|
162
|
+
* secret:string, storefrontUrl?:string|null, yes?:boolean }} params
|
|
163
|
+
* @param {{ fetch?:typeof fetch, confirmPlan?:typeof printPlanAndConfirm }} [deps]
|
|
164
|
+
* @returns {Promise<number>} process exit code
|
|
165
|
+
*/
|
|
166
|
+
export async function runIntegrate(
|
|
167
|
+
{ tenant, pr, changeId, headSha, secret, storefrontUrl = null, yes = false },
|
|
168
|
+
deps = {},
|
|
169
|
+
) {
|
|
170
|
+
const fetchImpl = deps.fetch || globalThis.fetch;
|
|
171
|
+
const confirmPlan = deps.confirmPlan || printPlanAndConfirm;
|
|
172
|
+
const base = (storefrontUrl || DEFAULT_STOREFRONT_URL).trim().replace(/\/+$/, "");
|
|
173
|
+
const label = pr != null ? `PR #${pr}` : changeId;
|
|
174
|
+
|
|
175
|
+
// 1. State the EXACT plan (shared U10 affordance) — queue-integrate-into-preview,
|
|
176
|
+
// NO merge, NO go-live — and gate on an explicit confirm.
|
|
177
|
+
const planLines = planForAction({ action: "accept", tenant, pr, changeId, headSha });
|
|
178
|
+
const { confirmed, reason } = await confirmPlan(planLines, {
|
|
179
|
+
yes,
|
|
180
|
+
question: `Queue ${label} for integration into ${tenant}'s preview aggregate?`,
|
|
181
|
+
});
|
|
182
|
+
if (!confirmed) {
|
|
183
|
+
if (reason === "non-tty") {
|
|
184
|
+
console.error(
|
|
185
|
+
fail(
|
|
186
|
+
"refusing to integrate without confirmation on a non-TTY.",
|
|
187
|
+
"re-run with --yes (an explicit human affirmative), or from an interactive terminal.",
|
|
188
|
+
),
|
|
189
|
+
);
|
|
190
|
+
return 2;
|
|
191
|
+
}
|
|
192
|
+
console.log("Aborted — nothing was integrated.");
|
|
193
|
+
return 1;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 2. Operator-secret transport — the CLI holds no storefront cookie, so the
|
|
197
|
+
// Bearer + X-Tot-Owner path is its route (same as `tot ship --pr`).
|
|
198
|
+
if (!secret) {
|
|
199
|
+
console.error(
|
|
200
|
+
fail(
|
|
201
|
+
"integrating a PR is an OPERATOR action — it needs an operator secret",
|
|
202
|
+
"set PREVIEW_RECONCILE_SECRET (or GRANTS_ADMIN_SECRET / TOT_OPERATOR_SECRET), or pass --secret",
|
|
203
|
+
),
|
|
204
|
+
);
|
|
205
|
+
return 2;
|
|
206
|
+
}
|
|
207
|
+
const authHeaders = {
|
|
208
|
+
"content-type": "application/json",
|
|
209
|
+
authorization: `Bearer ${secret}`,
|
|
210
|
+
"x-tot-owner": tenant,
|
|
211
|
+
"x-tot-capability": "ship-on-behalf",
|
|
212
|
+
};
|
|
213
|
+
const requestBody = {
|
|
214
|
+
repo: tenant,
|
|
215
|
+
...(pr != null ? { prNumber: pr } : {}),
|
|
216
|
+
...(changeId ? { changeId } : {}),
|
|
217
|
+
...(headSha ? { expectedHeadSha: headSha } : {}),
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
// 3. POST the honest accept path (b07 queue enqueue) and render the terminal state.
|
|
221
|
+
let res;
|
|
222
|
+
try {
|
|
223
|
+
res = await fetchImpl(`${base}/api/changes/integrate`, {
|
|
224
|
+
method: "POST",
|
|
225
|
+
headers: authHeaders,
|
|
226
|
+
body: JSON.stringify(requestBody),
|
|
227
|
+
});
|
|
228
|
+
} catch (e) {
|
|
229
|
+
console.error(
|
|
230
|
+
fail(`couldn't reach the integration queue at ${base}: ${String(e?.message || e)}`, "check --url / your network, then re-run"),
|
|
231
|
+
);
|
|
232
|
+
return 1;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let data = {};
|
|
236
|
+
try {
|
|
237
|
+
data = await res.json();
|
|
238
|
+
} catch {
|
|
239
|
+
/* non-JSON / empty body */
|
|
240
|
+
}
|
|
241
|
+
const result = normalizeIntegrateResponse(data);
|
|
242
|
+
|
|
243
|
+
// A pre-flight error (auth, unknown tenant, candidate not found) is an HTTP 4xx
|
|
244
|
+
// with `{ error }` and no queue verdict — surface it distinctly from a red run.
|
|
245
|
+
if (!res.ok && result.queueState == null) {
|
|
246
|
+
const msg = result.error || `HTTP ${res.status}`;
|
|
247
|
+
console.error(
|
|
248
|
+
fail(
|
|
249
|
+
`the integration queue refused the request: ${msg}`,
|
|
250
|
+
res.status === 401 || res.status === 403
|
|
251
|
+
? "check the operator secret and that it's authorised for this tenant"
|
|
252
|
+
: res.status === 404
|
|
253
|
+
? `check that ${label} has a built candidate in ${tenant}'s queue (\`tot pr list --tenant ${tenant}\`)`
|
|
254
|
+
: "check --tenant / --url / --pr, then re-run",
|
|
255
|
+
),
|
|
256
|
+
);
|
|
257
|
+
return 1;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return reportIntegrated(result, { tenant, label });
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* @param {string[]} argv
|
|
265
|
+
* @param {any} ctx
|
|
266
|
+
*/
|
|
267
|
+
export async function run(argv, ctx) {
|
|
268
|
+
const env = process.env;
|
|
269
|
+
const args = parseAcceptArgs(argv);
|
|
270
|
+
if (args.help) {
|
|
271
|
+
console.log(USAGE);
|
|
272
|
+
return 0;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const tenant = (args.tenant || ctx?.tenant || "").trim();
|
|
276
|
+
if (!tenant) {
|
|
277
|
+
console.error(
|
|
278
|
+
fail(
|
|
279
|
+
"no target tenant.",
|
|
280
|
+
"pass --tenant <appDomain> (e.g. --tenant tokenoftrust.com), or run inside a store checkout.",
|
|
281
|
+
),
|
|
282
|
+
);
|
|
283
|
+
return 2;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const prRaw = args.pr;
|
|
287
|
+
const pr =
|
|
288
|
+
prRaw != null && `${prRaw}`.trim() && Number.isFinite(Number(prRaw)) ? Number(prRaw) : null;
|
|
289
|
+
const changeId = (args.changeId || "").trim() || null;
|
|
290
|
+
if (pr == null && !changeId) {
|
|
291
|
+
console.error(
|
|
292
|
+
fail(
|
|
293
|
+
"no PR or change to integrate.",
|
|
294
|
+
"pass --pr <N> (the PR number to queue), or --change-id <id> to target a specific record.",
|
|
295
|
+
),
|
|
296
|
+
);
|
|
297
|
+
return 2;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const headSha = (args.headSha || "").trim() || null;
|
|
301
|
+
const storefrontUrl =
|
|
302
|
+
args.url || env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL;
|
|
303
|
+
|
|
304
|
+
return await runIntegrate({
|
|
305
|
+
tenant,
|
|
306
|
+
pr,
|
|
307
|
+
changeId,
|
|
308
|
+
headSha,
|
|
309
|
+
secret: resolveOperatorSecret(args.secret, env),
|
|
310
|
+
storefrontUrl,
|
|
311
|
+
yes: args.yes,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tot branches` — the full branch-cleanup REPORT for one tenant's repo (P1
|
|
3
|
+
* item 9 of the branch-lifecycle-and-integration-preview contract: "join
|
|
4
|
+
* branch, PR, candidate, head SHA, actor, full zoned timestamp, age, evidence,
|
|
5
|
+
* aggregate status, and cleanup eligibility"). Distinct from `tot pr list`,
|
|
6
|
+
* which only shows OPEN PRs — this shows EVERY branch on the repo (protected,
|
|
7
|
+
* active, stale, integrated, closed, orphaned), because that is what a
|
|
8
|
+
* cleanup decision needs.
|
|
9
|
+
*
|
|
10
|
+
* The report itself is server-side classification (b13's `candidate_list` MCP
|
|
11
|
+
* tool → `classifyBranches`); this command is presentation only — it never
|
|
12
|
+
* re-derives eligibility, an "aggregate" verdict, or a classification bucket
|
|
13
|
+
* client-side. The contract's complaint about a "truncated human table" (see
|
|
14
|
+
* "Candidate branch retirement") is about hidden precision, not columns — so
|
|
15
|
+
* every SHA and timestamp is printed in full, never shortened.
|
|
16
|
+
*
|
|
17
|
+
* KNOWN GAP: the contract asks for an "actor" column, but neither Gitea's
|
|
18
|
+
* branch record nor its PR record (see `../../gitea/gitea-rest.ts`) carries a
|
|
19
|
+
* PR author — `candidate_list` does not surface one. Rather than fabricate it
|
|
20
|
+
* client-side, every row prints `actor: —` and the report states the gap once
|
|
21
|
+
* up top. Fixing this is a tot-mcp follow-up (surface the PR author from
|
|
22
|
+
* Gitea), not something this CLI can honestly synthesize.
|
|
23
|
+
*
|
|
24
|
+
* `tot cleanup` (cleanup.mjs) reuses `resolveRepoAndTenant` from here so the
|
|
25
|
+
* two commands resolve "which repo" identically.
|
|
26
|
+
*
|
|
27
|
+
* Dependency-free (global fetch via mcp.mjs's client + `git`).
|
|
28
|
+
*/
|
|
29
|
+
import { execFileSync } from "node:child_process";
|
|
30
|
+
import { createMcpClient } from "../mcp.mjs";
|
|
31
|
+
import { establishSession, AuthUnavailableError } from "../auth.mjs";
|
|
32
|
+
import { fail } from "../errors.mjs";
|
|
33
|
+
import { repoNameFromRemote } from "./submit.mjs";
|
|
34
|
+
|
|
35
|
+
const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
|
|
36
|
+
|
|
37
|
+
const USAGE = `tot branches — full branch-cleanup report (every branch, not just open PRs)
|
|
38
|
+
|
|
39
|
+
tot branches report for the current checkout's repo
|
|
40
|
+
tot branches --tenant <t> report for <t> (works without a checkout)
|
|
41
|
+
tot branches --json emit the raw classification report as JSON
|
|
42
|
+
|
|
43
|
+
Joins branch + PR (any state) + candidate ref + head SHA + full zoned
|
|
44
|
+
timestamp + age + evidence + aggregate status + cleanup eligibility for
|
|
45
|
+
EVERY branch on the repo — read-only, always safe to run, and the report
|
|
46
|
+
\`tot cleanup\` reuses before it deletes anything.
|
|
47
|
+
|
|
48
|
+
Options:
|
|
49
|
+
--tenant <appDomain> Target tenant/repo. Defaults to the current
|
|
50
|
+
checkout's tenant when run inside one.
|
|
51
|
+
--repo <name> Explicit repo name, if it differs from --tenant.
|
|
52
|
+
--stale-after-days <n> Idle-day threshold for the "stale-open" warn
|
|
53
|
+
bucket. Defaults to the server's policy (14).
|
|
54
|
+
--mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
|
|
55
|
+
--identity <id> Prefer this cached identity over the default.
|
|
56
|
+
--json Print the raw classification report as JSON.
|
|
57
|
+
--help, -h Show this help.`;
|
|
58
|
+
|
|
59
|
+
/** Parse `tot branches` argv. Pure — unit-testable. */
|
|
60
|
+
export function parseBranchesArgs(argv) {
|
|
61
|
+
const a = {
|
|
62
|
+
tenant: null,
|
|
63
|
+
repo: null,
|
|
64
|
+
staleAfterDays: null,
|
|
65
|
+
mcp: null,
|
|
66
|
+
identity: null,
|
|
67
|
+
json: false,
|
|
68
|
+
help: false,
|
|
69
|
+
};
|
|
70
|
+
for (let i = 0; i < argv.length; i++) {
|
|
71
|
+
const t = argv[i];
|
|
72
|
+
if (t === "--tenant") a.tenant = argv[++i];
|
|
73
|
+
else if (t === "--repo") a.repo = argv[++i];
|
|
74
|
+
else if (t === "--stale-after-days") a.staleAfterDays = Number(argv[++i]);
|
|
75
|
+
else if (t === "--mcp") a.mcp = argv[++i];
|
|
76
|
+
else if (t === "--identity") a.identity = argv[++i];
|
|
77
|
+
else if (t === "--json") a.json = true;
|
|
78
|
+
else if (t === "--help" || t === "-h") a.help = true;
|
|
79
|
+
}
|
|
80
|
+
return a;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Best-effort `git remote get-url origin` from a checkout ctx. Never throws. */
|
|
84
|
+
function defaultReadRemoteUrl(ctx) {
|
|
85
|
+
try {
|
|
86
|
+
return execFileSync("git", ["-C", ctx.workspacePath, "remote", "get-url", "origin"], {
|
|
87
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
88
|
+
})
|
|
89
|
+
.toString()
|
|
90
|
+
.trim();
|
|
91
|
+
} catch {
|
|
92
|
+
return "";
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Resolve which repo + tenant a branches/cleanup call targets: an explicit
|
|
98
|
+
* `--repo`/`--tenant` wins; otherwise, inside a checkout, the repo is derived
|
|
99
|
+
* from the checkout's forge remote (same derivation `tot pr` uses) and the
|
|
100
|
+
* tenant from the detected context. `repo` (the Gitea repo/tenant-appDomain
|
|
101
|
+
* name candidate_list expects) falls back to the tenant when neither an
|
|
102
|
+
* explicit `--repo` nor a checkout is available — this codebase's own naming
|
|
103
|
+
* convention is that the repo name IS the tenant appDomain. Pure given
|
|
104
|
+
* `deps.readRemoteUrl` — unit-testable with no real git/network.
|
|
105
|
+
* @param {{tenant?:string|null, repo?:string|null}} args
|
|
106
|
+
* @param {any} ctx
|
|
107
|
+
* @param {{ readRemoteUrl?: (ctx:any) => string }} [deps]
|
|
108
|
+
* @returns {{ repo: string|null, tenant: string|null, error: string|null }}
|
|
109
|
+
*/
|
|
110
|
+
export function resolveRepoAndTenant(args, ctx, deps = {}) {
|
|
111
|
+
const readRemoteUrl = deps.readRemoteUrl || defaultReadRemoteUrl;
|
|
112
|
+
const tenant = (args.tenant || ctx?.tenant || "").trim() || null;
|
|
113
|
+
let repo = (args.repo || "").trim() || null;
|
|
114
|
+
if (!repo && ctx?.mode === "checkout" && ctx?.workspacePath) {
|
|
115
|
+
const remote = readRemoteUrl(ctx);
|
|
116
|
+
repo = remote ? repoNameFromRemote(remote) : null;
|
|
117
|
+
}
|
|
118
|
+
if (!repo) repo = tenant;
|
|
119
|
+
if (!repo) {
|
|
120
|
+
return {
|
|
121
|
+
repo: null,
|
|
122
|
+
tenant,
|
|
123
|
+
error: fail(
|
|
124
|
+
"no target repo/tenant.",
|
|
125
|
+
"pass --tenant <appDomain> (e.g. --tenant tokenoftrust.com), or run inside a store checkout.",
|
|
126
|
+
),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
return { repo, tenant: tenant || repo, error: null };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const COLUMNS = [
|
|
133
|
+
["branch", "BRANCH"],
|
|
134
|
+
["pr", "PR"],
|
|
135
|
+
["candidate", "CANDIDATE"],
|
|
136
|
+
["sha", "HEAD SHA"],
|
|
137
|
+
["actor", "ACTOR"],
|
|
138
|
+
["updated", "UPDATED (UTC)"],
|
|
139
|
+
["age", "AGE"],
|
|
140
|
+
["evidence", "EVIDENCE"],
|
|
141
|
+
["status", "STATUS"],
|
|
142
|
+
["aggregate", "AGGREGATE"],
|
|
143
|
+
["cleanup", "CLEANUP"],
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Adapt one `candidate_list` `BranchCleanupRecord` to this table's row shape.
|
|
148
|
+
* Every value is printed in full — no truncated SHAs, no truncated
|
|
149
|
+
* timestamps (see module header re: the contract's "truncated human table"
|
|
150
|
+
* complaint). Pure — unit-tested.
|
|
151
|
+
* @param {any} b - a BranchCleanupRecord from the MCP `candidate_list` tool
|
|
152
|
+
*/
|
|
153
|
+
export function formatBranchRow(b) {
|
|
154
|
+
const pr = typeof b.prNumber === "number" ? `#${b.prNumber}` : "#—";
|
|
155
|
+
const candidate = b.changeId || "—";
|
|
156
|
+
const sha = b.sha || "—";
|
|
157
|
+
// KNOWN GAP — see module header: candidate_list does not surface a PR
|
|
158
|
+
// author today, so this is honestly "—", never fabricated.
|
|
159
|
+
const actor = "—";
|
|
160
|
+
const updated = b.commitTimestampUtc || "(unknown)";
|
|
161
|
+
const age = b.idleDays != null ? `${b.idleDays}d` : "(unknown)";
|
|
162
|
+
const evidence = b.evidence
|
|
163
|
+
? `${b.evidence.checksTotal} check(s) ${b.evidence.allPass ? "all pass" : "NOT all pass"} @ ${b.evidence.generatedAtUtc}`
|
|
164
|
+
: "no evidence";
|
|
165
|
+
const aggregate =
|
|
166
|
+
b.classification === "protected" ? "—" : b.integratedIntoAggregate ? "in preview aggregate" : "not in aggregate";
|
|
167
|
+
const cleanup = b.eligibleForDelete ? "ELIGIBLE" : b.quarantine ? "QUARANTINED" : "keep";
|
|
168
|
+
return {
|
|
169
|
+
branch: b.ref,
|
|
170
|
+
pr,
|
|
171
|
+
candidate,
|
|
172
|
+
sha,
|
|
173
|
+
actor,
|
|
174
|
+
updated,
|
|
175
|
+
age,
|
|
176
|
+
evidence,
|
|
177
|
+
status: b.classification ?? "?",
|
|
178
|
+
aggregate,
|
|
179
|
+
cleanup,
|
|
180
|
+
reason: b.reason ?? "",
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Render already-formatted rows as one padded, legible table (header + one
|
|
186
|
+
* row per branch + an indented `reason:` line — the reason text is often
|
|
187
|
+
* longer than any fixed column would comfortably hold). Pure — unit-tested.
|
|
188
|
+
* @param {ReturnType<typeof formatBranchRow>[]} rows
|
|
189
|
+
* @returns {string[]}
|
|
190
|
+
*/
|
|
191
|
+
export function renderBranchesTable(rows) {
|
|
192
|
+
if (!rows.length) return ["(no branches)"];
|
|
193
|
+
const widths = COLUMNS.map(([key, label]) =>
|
|
194
|
+
Math.max(label.length, ...rows.map((r) => String(r[key] ?? "").length)),
|
|
195
|
+
);
|
|
196
|
+
const renderLine = (vals) =>
|
|
197
|
+
COLUMNS.map(([key], i) => String(vals[key] ?? "").padEnd(widths[i])).join(" ").trimEnd();
|
|
198
|
+
const lines = [renderLine(Object.fromEntries(COLUMNS.map(([key, label]) => [key, label])))];
|
|
199
|
+
for (const r of rows) {
|
|
200
|
+
lines.push(renderLine(r));
|
|
201
|
+
if (r.reason) lines.push(` reason: ${r.reason}`);
|
|
202
|
+
}
|
|
203
|
+
return lines;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Render the full `candidate_list` report: header, the table, then the
|
|
208
|
+
* server-computed summary/quarantine counts (never recomputed client-side —
|
|
209
|
+
* `candidate_list` already returns `summary` and `quarantined`). Pure —
|
|
210
|
+
* unit-tested.
|
|
211
|
+
* @param {any} report - the raw `candidate_list` result
|
|
212
|
+
* @returns {string[]}
|
|
213
|
+
*/
|
|
214
|
+
export function renderBranchesReport(report) {
|
|
215
|
+
const lines = [];
|
|
216
|
+
const repoLabel = report?.repo || "(unknown repo)";
|
|
217
|
+
lines.push(
|
|
218
|
+
`Branches for ${repoLabel} (org ${report?.org || "?"}) — generated ${report?.generatedAtUtc || "(unknown)"} ` +
|
|
219
|
+
`(stale-after ${report?.staleAfterDays ?? "?"}d)`,
|
|
220
|
+
);
|
|
221
|
+
lines.push('NOTE: "actor" is not yet surfaced by the server\'s branch-cleanup report — shown as "—" below.');
|
|
222
|
+
lines.push("");
|
|
223
|
+
lines.push(...renderBranchesTable((report?.branches || []).map(formatBranchRow)));
|
|
224
|
+
lines.push("");
|
|
225
|
+
const summary = report?.summary || {};
|
|
226
|
+
const summaryParts = Object.entries(summary)
|
|
227
|
+
.map(([k, v]) => `${k}: ${v}`)
|
|
228
|
+
.join(", ");
|
|
229
|
+
lines.push(`Summary — ${report?.branches?.length ?? 0} branch(es): ${summaryParts || "(none)"}`);
|
|
230
|
+
if (Array.isArray(report?.quarantined) && report.quarantined.length) {
|
|
231
|
+
lines.push(
|
|
232
|
+
`Quarantined orphans (${report.quarantined.length}) — no PR record in any state; NEVER auto-deleted: ` +
|
|
233
|
+
report.quarantined.join(", "),
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
return lines;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** @param {string[]} argv @param {any} ctx */
|
|
240
|
+
export async function run(argv, ctx) {
|
|
241
|
+
const env = process.env;
|
|
242
|
+
const args = parseBranchesArgs(argv);
|
|
243
|
+
if (args.help) {
|
|
244
|
+
console.log(USAGE);
|
|
245
|
+
return 0;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const { repo, tenant, error } = resolveRepoAndTenant(args, ctx);
|
|
249
|
+
if (error) {
|
|
250
|
+
console.error(error);
|
|
251
|
+
return 2;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
|
|
255
|
+
const client = createMcpClient(baseUrl);
|
|
256
|
+
try {
|
|
257
|
+
await establishSession(client, { env, prefer: args.identity || undefined });
|
|
258
|
+
if (tenant) await client.callTool("client_switch", { tenant });
|
|
259
|
+
|
|
260
|
+
const report = await client.callTool("candidate_list", {
|
|
261
|
+
repo,
|
|
262
|
+
...(args.staleAfterDays != null && Number.isFinite(args.staleAfterDays)
|
|
263
|
+
? { staleAfterDays: args.staleAfterDays }
|
|
264
|
+
: {}),
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
if (report?.status === "error" || report?.status === "invalid_input" || report?.error) {
|
|
268
|
+
console.error(
|
|
269
|
+
fail(
|
|
270
|
+
`couldn't list branches for ${repo}: ${report.error || report.message || "unknown error"}`,
|
|
271
|
+
"check --tenant / --repo, then re-run",
|
|
272
|
+
),
|
|
273
|
+
);
|
|
274
|
+
return 1;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (args.json) {
|
|
278
|
+
console.log(JSON.stringify(report, null, 2));
|
|
279
|
+
return 0;
|
|
280
|
+
}
|
|
281
|
+
console.log(renderBranchesReport(report).join("\n"));
|
|
282
|
+
return 0;
|
|
283
|
+
} catch (e) {
|
|
284
|
+
if (e instanceof AuthUnavailableError) {
|
|
285
|
+
console.error(fail("sign in to list branches", e.hint || "run `tot login`, then re-run"));
|
|
286
|
+
return 1;
|
|
287
|
+
}
|
|
288
|
+
console.error(
|
|
289
|
+
fail(
|
|
290
|
+
`couldn't reach the candidate service: ${String(e?.message || e)}`,
|
|
291
|
+
"check your connection and that you're signed in, then re-run",
|
|
292
|
+
),
|
|
293
|
+
);
|
|
294
|
+
return 1;
|
|
295
|
+
}
|
|
296
|
+
}
|