@tablation/crew 0.85.0 → 0.86.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/dist/cli.js +261 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -6905,6 +6905,33 @@ var Tracker = class {
|
|
|
6905
6905
|
const { shipId } = await this.authorshipProbe();
|
|
6906
6906
|
return this.client.records.create(modelId, { ...fields, ...shipId ? { ship_id: shipId } : {} });
|
|
6907
6907
|
}
|
|
6908
|
+
/**
|
|
6909
|
+
* This ship's Periscopes rows (EPIC-077), newest first, with the ticket each
|
|
6910
|
+
* belongs to. Empty when there is no Periscopes table or no Ships row yet.
|
|
6911
|
+
*/
|
|
6912
|
+
async periscopeRows(states) {
|
|
6913
|
+
const modelId = this.route.resolved?.periscopesModelId;
|
|
6914
|
+
const { shipId } = await this.authorshipProbe();
|
|
6915
|
+
if (!modelId || !shipId) return [];
|
|
6916
|
+
const conds = [{ columnName: "ship_id", operator: "EQ", value: shipId }];
|
|
6917
|
+
if (states?.length) conds.push({ columnName: "state", operator: "IN", value: states });
|
|
6918
|
+
const rows = await this.client.records.list(modelId, { filters: encodeFilters(conds), limit: 200 });
|
|
6919
|
+
const refId = (v) => typeof v === "string" ? v : v && typeof v === "object" && typeof v.id === "string" ? v.id : void 0;
|
|
6920
|
+
const ids = [...new Set(rows.map((r) => refId(r.issue_id)).filter((x) => !!x))];
|
|
6921
|
+
const tickets = new Map((await this.ticketsByIds(ids)).map((t) => [t.id, t]));
|
|
6922
|
+
const out = [];
|
|
6923
|
+
for (const row of rows) {
|
|
6924
|
+
const t = tickets.get(refId(row.issue_id) ?? "");
|
|
6925
|
+
if (t) out.push({ row, ticket: t });
|
|
6926
|
+
}
|
|
6927
|
+
return out.sort((a, b) => String(b.row.started_at ?? "").localeCompare(String(a.row.started_at ?? "")));
|
|
6928
|
+
}
|
|
6929
|
+
/** Updates one Periscopes row (EPIC-077). */
|
|
6930
|
+
async updatePeriscope(id, patch2) {
|
|
6931
|
+
const modelId = this.route.resolved?.periscopesModelId;
|
|
6932
|
+
if (!modelId) throw new Error("this workspace has no Periscopes table");
|
|
6933
|
+
await this.client.records.update(modelId, id, patch2);
|
|
6934
|
+
}
|
|
6908
6935
|
/**
|
|
6909
6936
|
* The authorship columns this workspace is missing, as `table.column` names
|
|
6910
6937
|
* for `crew doctor` to report (CREW-1371). Empty on a fully updated install.
|
|
@@ -16532,6 +16559,109 @@ ${tail(preview.output)}`);
|
|
|
16532
16559
|
});
|
|
16533
16560
|
return { ticket: draft.tag, ticketRecordId: draft.id, worktree: wt.path, branch: wt.branch, url, periscopeRecordId: row.id };
|
|
16534
16561
|
}
|
|
16562
|
+
function parsePeriscopeDownArgs(positional2, flags) {
|
|
16563
|
+
const usage2 = "usage: crew periscope down [route] NNN --keep|--discard [--allow-empty] [--json]";
|
|
16564
|
+
const rest = positional2.slice(2);
|
|
16565
|
+
if (rest.length === 0 || rest.length > 2) throw new PeriscopeError(usage2);
|
|
16566
|
+
if (flags.keep === flags.discard) throw new PeriscopeError(`pass exactly one of --keep or --discard
|
|
16567
|
+
${usage2}`);
|
|
16568
|
+
const m = /^(?:[A-Za-z]+-)?(\d+)$/.exec(rest[rest.length - 1]);
|
|
16569
|
+
if (!m) throw new PeriscopeError(`"${rest[rest.length - 1]}" is not a ticket number
|
|
16570
|
+
${usage2}`);
|
|
16571
|
+
return {
|
|
16572
|
+
...rest.length === 2 ? { route: rest[0] } : {},
|
|
16573
|
+
number: m[1],
|
|
16574
|
+
mode: flags.keep ? "keep" : "discard",
|
|
16575
|
+
allowEmpty: flags.allowEmpty,
|
|
16576
|
+
json: flags.json
|
|
16577
|
+
};
|
|
16578
|
+
}
|
|
16579
|
+
var PROTOTYPE_HEADING = "## Prototype";
|
|
16580
|
+
function prototypeSection(files) {
|
|
16581
|
+
const added = files.reduce((n, f) => n + f.added, 0);
|
|
16582
|
+
const removed = files.reduce((n, f) => n + f.removed, 0);
|
|
16583
|
+
return [
|
|
16584
|
+
PROTOTYPE_HEADING,
|
|
16585
|
+
"",
|
|
16586
|
+
"This branch began as a Periscope prototype and may be rewritten rather than treated as reviewed work.",
|
|
16587
|
+
"",
|
|
16588
|
+
...files.length === 0 ? ["No files differ from the base."] : files.map((f) => `- \`${f.path}\` (+${f.added} \u2212${f.removed})`),
|
|
16589
|
+
"",
|
|
16590
|
+
`${files.length} file${files.length === 1 ? "" : "s"} changed, ${added} insertion${added === 1 ? "" : "s"}(+), ${removed} deletion${removed === 1 ? "" : "s"}(\u2212)`
|
|
16591
|
+
].join("\n");
|
|
16592
|
+
}
|
|
16593
|
+
async function runPeriscopeDown(d, args) {
|
|
16594
|
+
const rows = await d.rowsForTicket(args.number);
|
|
16595
|
+
if (rows.length === 0) throw new PeriscopeError(`no periscope for ticket ${args.number} on this ship`);
|
|
16596
|
+
const row = rows.find((r) => r.state === "live");
|
|
16597
|
+
if (!row) {
|
|
16598
|
+
const done = rows[0];
|
|
16599
|
+
d.say("unchanged", `${done.ticket} is already ${done.state}; nothing to do`);
|
|
16600
|
+
return { ticket: done.ticket, mode: args.mode, outcome: "unchanged", state: done.state };
|
|
16601
|
+
}
|
|
16602
|
+
if (args.mode === "discard") {
|
|
16603
|
+
const killed2 = await d.stopServer(row.worktree, row.number);
|
|
16604
|
+
d.say("server", killed2 ? `stopped ${killed2} process(es)` : "no server was listening");
|
|
16605
|
+
d.removeWorktree(row.worktree, row.branch, true);
|
|
16606
|
+
d.say("worktree", `removed ${row.worktree} and branch ${row.branch}`);
|
|
16607
|
+
await d.updateTicket(row.ticketRecordId, {
|
|
16608
|
+
status: "discarded",
|
|
16609
|
+
fillProjectAndRepo: false,
|
|
16610
|
+
note: "The periscope was discarded; nothing was pushed."
|
|
16611
|
+
});
|
|
16612
|
+
d.say("ticket", `${row.ticket} closed as obsolete`);
|
|
16613
|
+
await d.setRowState(row.id, "discarded");
|
|
16614
|
+
d.say("row", "row is discarded");
|
|
16615
|
+
return { ticket: row.ticket, mode: "discard", outcome: "done", state: "discarded" };
|
|
16616
|
+
}
|
|
16617
|
+
const dirty = d.hasChanges(row.worktree);
|
|
16618
|
+
if (!dirty && !args.allowEmpty) {
|
|
16619
|
+
throw new PeriscopeError(`${row.ticket} has nothing to commit; pass --allow-empty to keep the branch anyway, or --discard`);
|
|
16620
|
+
}
|
|
16621
|
+
if (dirty) {
|
|
16622
|
+
d.commitAll(row.worktree, `${row.ticket} Periscope prototype`);
|
|
16623
|
+
d.say("commit", `committed the worktree on ${row.branch}`);
|
|
16624
|
+
}
|
|
16625
|
+
const pushed = d.push(row.worktree, row.branch);
|
|
16626
|
+
d.say("push", `pushed ${pushed.sha} to ${pushed.remote}/${row.branch}`, { sha: pushed.sha });
|
|
16627
|
+
const t = await d.readTicket(row.ticketRecordId);
|
|
16628
|
+
const section = prototypeSection(d.changedFiles(row.worktree));
|
|
16629
|
+
const description = t.description.includes(PROTOTYPE_HEADING) ? t.description : `${t.description.trimEnd()}
|
|
16630
|
+
|
|
16631
|
+
${section}`;
|
|
16632
|
+
await d.updateTicket(row.ticketRecordId, {
|
|
16633
|
+
description,
|
|
16634
|
+
fillProjectAndRepo: !t.hasProject || !t.hasRepo,
|
|
16635
|
+
status: "new"
|
|
16636
|
+
});
|
|
16637
|
+
d.say("ticket", `${row.ticket} is new and unassigned with a Prototype section`);
|
|
16638
|
+
await d.postPushed(row.ticketRecordId, pushed.sha, pushed.remote, row.branch);
|
|
16639
|
+
const killed = await d.stopServer(row.worktree, row.number);
|
|
16640
|
+
d.say("server", killed ? `stopped ${killed} process(es)` : "no server was listening");
|
|
16641
|
+
d.removeWorktree(row.worktree, row.branch, false);
|
|
16642
|
+
d.say("worktree", `removed ${row.worktree}; ${row.branch} stays, pushed`);
|
|
16643
|
+
await d.setRowState(row.id, "kept");
|
|
16644
|
+
d.say("row", "row is kept");
|
|
16645
|
+
return { ticket: row.ticket, mode: "keep", outcome: "done", state: "kept", sha: pushed.sha };
|
|
16646
|
+
}
|
|
16647
|
+
function periscopeListEntries(rows, now, isListening) {
|
|
16648
|
+
return rows.filter((r) => r.state === "live").map((r) => {
|
|
16649
|
+
const at = r.lastActivityAt ? new Date(r.lastActivityAt).getTime() : NaN;
|
|
16650
|
+
return { ...r, idleMinutes: Number.isNaN(at) ? null : Math.max(0, Math.floor((now.getTime() - at) / 6e4)), listening: isListening(r) };
|
|
16651
|
+
});
|
|
16652
|
+
}
|
|
16653
|
+
function formatPeriscopeList(entries) {
|
|
16654
|
+
if (entries.length === 0) return "no live periscopes on this ship";
|
|
16655
|
+
return entries.map((e) => [
|
|
16656
|
+
e.ticket,
|
|
16657
|
+
e.repo,
|
|
16658
|
+
e.worktree,
|
|
16659
|
+
e.url,
|
|
16660
|
+
e.state,
|
|
16661
|
+
e.idleMinutes === null ? "idle ?" : `idle ${e.idleMinutes} min`,
|
|
16662
|
+
e.listening ? "listening" : "not listening"
|
|
16663
|
+
].join(" ")).join("\n");
|
|
16664
|
+
}
|
|
16535
16665
|
|
|
16536
16666
|
// src/cli.ts
|
|
16537
16667
|
import { dirname as dirOf, resolve as resolvePath } from "path";
|
|
@@ -17996,6 +18126,9 @@ function usage(problem, requested = false) {
|
|
|
17996
18126
|
crew rotate-passenger-url [route] force a Host Passengers tunnel to reconnect with a fresh public URL
|
|
17997
18127
|
crew periscope up [route] REPO --topic TEXT [--isolate] [--host H] [--assignee ID] [--dry-run] [--json]
|
|
17998
18128
|
file a draft ticket, cut and prepare its worktree, start the repo's preview hook and record a Periscopes row (alias: start)
|
|
18129
|
+
crew periscope down [route] NNN --keep|--discard [--allow-empty] [--json]
|
|
18130
|
+
end a periscope: commit and push its branch (ticket back to new), or throw it away (alias: end)
|
|
18131
|
+
crew periscope list [route] [--json] the live periscopes on this ship, their idle minutes and whether each server is listening
|
|
17999
18132
|
crew worktree [route] NNN cut (or refresh) this run's worktree for a ticket from the remote; prints its path
|
|
18000
18133
|
crew unassign [route] NNN hand back a session's ticket \u2014 clears assignee, next cycle picks it up
|
|
18001
18134
|
crew sync [route] fast-forward the checkout and its worktrees from the remote
|
|
@@ -21422,14 +21555,141 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
21422
21555
|
}
|
|
21423
21556
|
case "periscope": {
|
|
21424
21557
|
const subP = positional[1];
|
|
21425
|
-
if (subP !== "up" && subP !== "start") {
|
|
21558
|
+
if (subP !== "up" && subP !== "start" && subP !== "down" && subP !== "end" && subP !== "list") {
|
|
21426
21559
|
process.stderr.write("crew periscope up [route] REPO --topic TEXT [--isolate] [--host H] [--assignee ID] [--dry-run] [--json]\n");
|
|
21560
|
+
process.stderr.write("crew periscope down [route] NNN --keep|--discard [--allow-empty] [--json]\n");
|
|
21561
|
+
process.stderr.write("crew periscope list [route] [--json]\n");
|
|
21427
21562
|
process.exit(2);
|
|
21428
21563
|
}
|
|
21429
21564
|
const jsonP = flag("json");
|
|
21430
21565
|
const say = (event, message, data = {}) => process.stderr.write(jsonP ? `${JSON.stringify({ ...data, event, message })}
|
|
21431
21566
|
` : `${message}
|
|
21432
21567
|
`);
|
|
21568
|
+
if (subP === "down" || subP === "end" || subP === "list") {
|
|
21569
|
+
try {
|
|
21570
|
+
const downArgs = subP === "list" ? void 0 : parsePeriscopeDownArgs(positional, { keep: flag("keep"), discard: flag("discard"), allowEmpty: flag("allow-empty"), json: jsonP });
|
|
21571
|
+
const routeName = subP === "list" ? positional[2] : downArgs.route;
|
|
21572
|
+
const routesP = (routeName ? [findRoute(cfg, routeName)] : cfg.routes.filter((r) => r.enabled)).filter((r) => r.resolved?.periscopesModelId);
|
|
21573
|
+
if (routesP.length === 0) throw new PeriscopeError("no connected route has a Periscopes table");
|
|
21574
|
+
const gather = async (rt) => {
|
|
21575
|
+
const tr = new Tracker(rt, cfg.ship);
|
|
21576
|
+
const repos = await resolvedRepos(rt);
|
|
21577
|
+
return (await tr.periscopeRows()).map(({ row, ticket }) => {
|
|
21578
|
+
const repo = repos.find((r) => r.id && r.id === ticket[tr.contract.columns.repo]) ?? (repos.length === 1 ? repos[0] : void 0);
|
|
21579
|
+
const str = (v) => typeof v === "string" ? v : "";
|
|
21580
|
+
return {
|
|
21581
|
+
id: row.id,
|
|
21582
|
+
ticketRecordId: ticket.id,
|
|
21583
|
+
ticket: ticket.issue_tag ?? ticket.issue_id,
|
|
21584
|
+
number: /(\d+)$/.exec(ticket.issue_id)?.[1] ?? "",
|
|
21585
|
+
repo: repo?.name ?? "?",
|
|
21586
|
+
state: str(row.state),
|
|
21587
|
+
url: str(row.url),
|
|
21588
|
+
worktree: str(row.worktree),
|
|
21589
|
+
branch: str(row.branch),
|
|
21590
|
+
lastActivityAt: str(row.last_activity_at) || null
|
|
21591
|
+
};
|
|
21592
|
+
});
|
|
21593
|
+
};
|
|
21594
|
+
if (subP === "list") {
|
|
21595
|
+
const entries = periscopeListEntries(
|
|
21596
|
+
(await Promise.all(routesP.map(gather))).flat(),
|
|
21597
|
+
/* @__PURE__ */ new Date(),
|
|
21598
|
+
(r) => pidsInWorktree(r.worktree, Number(r.number)).length > 0
|
|
21599
|
+
);
|
|
21600
|
+
process.stdout.write(jsonP ? `${JSON.stringify(entries)}
|
|
21601
|
+
` : `${formatPeriscopeList(entries)}
|
|
21602
|
+
`);
|
|
21603
|
+
break;
|
|
21604
|
+
}
|
|
21605
|
+
const a = downArgs;
|
|
21606
|
+
const hits = [];
|
|
21607
|
+
for (const rt of routesP) {
|
|
21608
|
+
const rows = (await gather(rt)).filter((r) => r.number === a.number);
|
|
21609
|
+
if (rows.length) hits.push({ rt, rows });
|
|
21610
|
+
}
|
|
21611
|
+
if (hits.length !== 1) {
|
|
21612
|
+
throw new PeriscopeError(hits.length === 0 ? `no periscope for ticket ${a.number} on this ship` : `ticket ${a.number} has periscopes on more than one route (${hits.map((h) => h.rt.route).join(", ")}) \u2014 name the route`);
|
|
21613
|
+
}
|
|
21614
|
+
const { rt: routeD, rows: rowsD } = hits[0];
|
|
21615
|
+
const trD = new Tracker(routeD, cfg.ship);
|
|
21616
|
+
const cD = trD.contract;
|
|
21617
|
+
const reposD = await resolvedRepos(routeD);
|
|
21618
|
+
const repoD = reposD.find((r) => r.name === rowsD[0].repo);
|
|
21619
|
+
if (!repoD) throw new PeriscopeError(`cannot tell which repository ${rowsD[0].ticket} belongs to`);
|
|
21620
|
+
const { remote, base } = repoD.config.branch;
|
|
21621
|
+
const memberD = routeD.resolved.seats.pair ?? routeD.resolved.operator;
|
|
21622
|
+
const closedD = cD.statuses.resolved.includes("closed_obsolete") ? "closed_obsolete" : cD.statuses.wontFix;
|
|
21623
|
+
const depsD = {
|
|
21624
|
+
rowsForTicket: async () => rowsD,
|
|
21625
|
+
hasChanges: (wt) => status(wt).length > 0,
|
|
21626
|
+
commitAll: (wt, msg) => {
|
|
21627
|
+
git(wt, ["add", "-A"]);
|
|
21628
|
+
git(wt, ["commit", "-m", msg]);
|
|
21629
|
+
},
|
|
21630
|
+
push: (wt, branch) => {
|
|
21631
|
+
pushBranchUpstream(wt, remote, branch);
|
|
21632
|
+
return { sha: headSha(wt), remote };
|
|
21633
|
+
},
|
|
21634
|
+
changedFiles: (wt) => {
|
|
21635
|
+
const out = gitOk(wt, ["diff", "--numstat", `${remote}/${base}...HEAD`]) ?? "";
|
|
21636
|
+
return out.split("\n").filter(Boolean).map((l) => {
|
|
21637
|
+
const [add, rem, ...p] = l.split(" ");
|
|
21638
|
+
return { path: p.join(" "), added: Number(add) || 0, removed: Number(rem) || 0 };
|
|
21639
|
+
});
|
|
21640
|
+
},
|
|
21641
|
+
readTicket: async (id) => {
|
|
21642
|
+
const t = await trD.ticket(id);
|
|
21643
|
+
return { description: String(t.description ?? ""), hasProject: !!t[cD.columns.slice], hasRepo: !!t[cD.columns.repo] };
|
|
21644
|
+
},
|
|
21645
|
+
updateTicket: async (id, p) => {
|
|
21646
|
+
const patch2 = {};
|
|
21647
|
+
if (p.status === "new") {
|
|
21648
|
+
patch2[cD.columns.status] = "new";
|
|
21649
|
+
patch2[cD.columns.assignee] = null;
|
|
21650
|
+
if (p.description !== void 0) patch2.description = p.description;
|
|
21651
|
+
if (p.fillProjectAndRepo) {
|
|
21652
|
+
if (routeD.resolved.areaId) patch2[cD.columns.slice] = routeD.resolved.areaId;
|
|
21653
|
+
if (repoD.id) patch2[cD.columns.repo] = repoD.id;
|
|
21654
|
+
}
|
|
21655
|
+
} else {
|
|
21656
|
+
patch2[cD.columns.status] = closedD;
|
|
21657
|
+
patch2[cD.columns.resolutionNote] = p.note;
|
|
21658
|
+
}
|
|
21659
|
+
await trD.updateTicket(id, patch2);
|
|
21660
|
+
},
|
|
21661
|
+
postPushed: (id, sha, rem, branch) => trD.postEvent(id, pushedEventBody(sha, rem, branch), memberD, "pushed"),
|
|
21662
|
+
stopServer: async (wt, n) => {
|
|
21663
|
+
const pids = pidsInWorktree(wt, Number(n));
|
|
21664
|
+
if (pids.length) await killGently(pids);
|
|
21665
|
+
return pids.length;
|
|
21666
|
+
},
|
|
21667
|
+
removeWorktree: (wt, branch, deleteBranch) => {
|
|
21668
|
+
gitOk(repoD.dir, ["worktree", "remove", "--force", wt]);
|
|
21669
|
+
if (deleteBranch) gitOk(repoD.dir, ["branch", "-D", branch]);
|
|
21670
|
+
},
|
|
21671
|
+
setRowState: (id, state2) => trD.updatePeriscope(id, { state: state2 }),
|
|
21672
|
+
say
|
|
21673
|
+
};
|
|
21674
|
+
if (dryRun) {
|
|
21675
|
+
say("dry-run", `would end the periscope for ${rowsD[0].ticket} (${a.mode})`);
|
|
21676
|
+
break;
|
|
21677
|
+
}
|
|
21678
|
+
const res = await runPeriscopeDown(depsD, a);
|
|
21679
|
+
if (jsonP) process.stdout.write(`${JSON.stringify({ event: "done", ...res })}
|
|
21680
|
+
`);
|
|
21681
|
+
else process.stdout.write(`${res.ticket} ${res.state}
|
|
21682
|
+
`);
|
|
21683
|
+
} catch (e) {
|
|
21684
|
+
if (e instanceof PeriscopeError || e instanceof ConfigError) {
|
|
21685
|
+
process.stderr.write(`crew periscope: ${e.message}
|
|
21686
|
+
`);
|
|
21687
|
+
process.exit(1);
|
|
21688
|
+
}
|
|
21689
|
+
throw e;
|
|
21690
|
+
}
|
|
21691
|
+
break;
|
|
21692
|
+
}
|
|
21433
21693
|
try {
|
|
21434
21694
|
const a = parsePeriscopeUpArgs(positional, { topic: value("topic"), host: value("host"), assignee: value("assignee"), isolate: flag("isolate"), json: jsonP });
|
|
21435
21695
|
const candidatesP = a.route ? [findRoute(cfg, a.route)] : cfg.routes.filter((r) => r.enabled);
|
package/package.json
CHANGED