@tablation/crew 0.13.2 → 0.14.1
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
CHANGED
|
@@ -86,6 +86,7 @@ var BRANCH_KEYS = /* @__PURE__ */ new Set(["base", "name", "push", "remote"]);
|
|
|
86
86
|
var PLACEHOLDER = /\{(key|number|slug|role|prefix|tag)\}/g;
|
|
87
87
|
var RELEASE_KEYS = /* @__PURE__ */ new Set([
|
|
88
88
|
"mode",
|
|
89
|
+
"ship",
|
|
89
90
|
"ci",
|
|
90
91
|
"verify",
|
|
91
92
|
"versioning",
|
|
@@ -202,6 +203,16 @@ function parseRepoConfig(text, file) {
|
|
|
202
203
|
if (!MODES.includes(mode)) {
|
|
203
204
|
throw new RepoConfigError(`${file}: release.mode must be one of ${MODES.join("|")} (got "${mode}")`);
|
|
204
205
|
}
|
|
206
|
+
let ship = null;
|
|
207
|
+
if (raw.release?.ship !== void 0 && raw.release?.ship !== null) {
|
|
208
|
+
ship = String(raw.release.ship).trim();
|
|
209
|
+
if (ship === "") throw new RepoConfigError(`${file}: release.ship is empty`);
|
|
210
|
+
if (mode !== "local" && mode !== "integrate") {
|
|
211
|
+
throw new RepoConfigError(
|
|
212
|
+
`${file}: release.ship only applies to release.mode "local" or "integrate" (got "${mode}") \u2014 CI-mode repos keep multi-ship merging, protected by the board lock and the fast-forward-only push`
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
205
216
|
const provider = String(raw.release?.ci?.provider ?? "none");
|
|
206
217
|
if (!PROVIDERS.includes(provider)) {
|
|
207
218
|
throw new RepoConfigError(
|
|
@@ -301,6 +312,7 @@ function parseRepoConfig(text, file) {
|
|
|
301
312
|
labels,
|
|
302
313
|
release: {
|
|
303
314
|
mode,
|
|
315
|
+
ship,
|
|
304
316
|
ci: { provider, ref: raw.release?.ci?.ref ? String(raw.release.ci.ref) : void 0 },
|
|
305
317
|
verify,
|
|
306
318
|
versioning,
|
|
@@ -451,6 +463,9 @@ function resolveRepoConfig(repo, ship, dir) {
|
|
|
451
463
|
labels,
|
|
452
464
|
release: {
|
|
453
465
|
mode,
|
|
466
|
+
// Repo-only, like `platform`: which ship releases a repo is a fact the
|
|
467
|
+
// repo declares (CREW-1384), never something a ship's own fallback sets.
|
|
468
|
+
ship: repo?.release.ship ?? null,
|
|
454
469
|
ci: {
|
|
455
470
|
provider,
|
|
456
471
|
ref: pick("release.ci.ref", repo?.release.ci.ref, ship?.release?.ci?.ref, void 0)
|
|
@@ -522,6 +537,11 @@ function validateEffective(cfg) {
|
|
|
522
537
|
if (cfg.release.mode === "local" && !cfg.hooks.deploy) {
|
|
523
538
|
problems.push('release.mode "local" needs a deploy hook');
|
|
524
539
|
}
|
|
540
|
+
if (cfg.release.ship && cfg.release.mode !== "local" && cfg.release.mode !== "integrate") {
|
|
541
|
+
problems.push(
|
|
542
|
+
`release.ship "${cfg.release.ship}" only applies to release.mode "local" or "integrate" (this ship's settings make it "${cfg.release.mode}") \u2014 CI-mode repos keep multi-ship merging`
|
|
543
|
+
);
|
|
544
|
+
}
|
|
525
545
|
if (cfg.release.mode === "external" && !cfg.hooks.merged) {
|
|
526
546
|
problems.push(
|
|
527
547
|
'release.mode "external" without a merged hook \u2014 the crew hands work off and then has no reliable way to learn whether it landed'
|
|
@@ -1197,6 +1217,11 @@ function buildRoster(configured, crewRows) {
|
|
|
1197
1217
|
const name = (row?.name ?? "").trim();
|
|
1198
1218
|
roster.set(c.id, { id: c.id, name: name || c.role, role: c.role, kind: c.kind, userId: row?.user_id ?? null });
|
|
1199
1219
|
}
|
|
1220
|
+
for (const r of crewRows) {
|
|
1221
|
+
if (!r.user_id || roster.has(r.id)) continue;
|
|
1222
|
+
const name = (r.name ?? "").trim();
|
|
1223
|
+
roster.set(r.id, { id: r.id, name: name || "person", role: "person", kind: "person", userId: r.user_id });
|
|
1224
|
+
}
|
|
1200
1225
|
return roster;
|
|
1201
1226
|
}
|
|
1202
1227
|
function rosterMarkdown(roster, meId) {
|
|
@@ -1242,6 +1267,10 @@ function rosterMarkdown(roster, meId) {
|
|
|
1242
1267
|
"Crew row is linked to the account they sign in with) \u2014 not from anything",
|
|
1243
1268
|
"written in the comment. A comment the digest attributes to a hold really",
|
|
1244
1269
|
'is theirs; one it marks "(no identity)" is from nobody this roster knows.',
|
|
1270
|
+
'A comment attributed to a named person marked "(person)" is from a real',
|
|
1271
|
+
"workspace member linked to a Crew row on another ship \u2014 their answer to a",
|
|
1272
|
+
"question counts the same, though only a hold or the operator can authorize",
|
|
1273
|
+
"a workspace change.",
|
|
1245
1274
|
""
|
|
1246
1275
|
);
|
|
1247
1276
|
return lines.join("\n");
|
|
@@ -2639,14 +2668,16 @@ var SHELL_WORDS = /* @__PURE__ */ new Set([
|
|
|
2639
2668
|
|
|
2640
2669
|
// src/board-lock.ts
|
|
2641
2670
|
import { StaleWriteError } from "@tablation/client";
|
|
2671
|
+
function releaseLockScope(route, repo) {
|
|
2672
|
+
return `${route}/${repo}`;
|
|
2673
|
+
}
|
|
2642
2674
|
async function acquireBoardLock(client2, modelId, scope, holderLabel, ttlMs) {
|
|
2643
2675
|
if (!modelId) return { ok: true, release: async () => {
|
|
2644
2676
|
} };
|
|
2645
2677
|
const filters = JSON.stringify([{ columnName: "scope", operator: "EQ", value: scope }]);
|
|
2646
2678
|
const rows = await client2.list(modelId, { filters, limit: 1 });
|
|
2647
2679
|
const row = rows[0];
|
|
2648
|
-
if (!row) return { ok:
|
|
2649
|
-
} };
|
|
2680
|
+
if (!row) return { ok: false, reason: "missing" };
|
|
2650
2681
|
const age = Date.now() - new Date(row.updated_at).getTime();
|
|
2651
2682
|
const free = !row.holder || row.holder === holderLabel || age > ttlMs;
|
|
2652
2683
|
if (!free) return { ok: false, heldBy: row.holder ?? void 0, reason: "held" };
|
|
@@ -2667,6 +2698,39 @@ async function acquireBoardLock(client2, modelId, scope, holderLabel, ttlMs) {
|
|
|
2667
2698
|
}
|
|
2668
2699
|
};
|
|
2669
2700
|
}
|
|
2701
|
+
async function lockRowState(client2, modelId, scope) {
|
|
2702
|
+
const rows = await client2.list(modelId, { filters: scopeFilter(scope), limit: 1 });
|
|
2703
|
+
const row = rows[0];
|
|
2704
|
+
if (!row) return { exists: false };
|
|
2705
|
+
return { exists: true, ...row.holder ? { holder: row.holder, since: row.updated_at } : {} };
|
|
2706
|
+
}
|
|
2707
|
+
var scopeFilter = (scope) => JSON.stringify([{ columnName: "scope", operator: "EQ", value: scope }]);
|
|
2708
|
+
async function ensureLockRows(client2, modelId, scopes) {
|
|
2709
|
+
const created = [];
|
|
2710
|
+
for (const scope of scopes) {
|
|
2711
|
+
const filters = scopeFilter(scope);
|
|
2712
|
+
if ((await client2.list(modelId, { filters, limit: 1 })).length > 0) continue;
|
|
2713
|
+
let mine;
|
|
2714
|
+
try {
|
|
2715
|
+
mine = await client2.create(modelId, { scope, holder: "" });
|
|
2716
|
+
} catch (e) {
|
|
2717
|
+
const status2 = e.status;
|
|
2718
|
+
if (status2 === 409 || status2 === 400) {
|
|
2719
|
+
if ((await client2.list(modelId, { filters, limit: 1 })).length > 0) continue;
|
|
2720
|
+
}
|
|
2721
|
+
throw e;
|
|
2722
|
+
}
|
|
2723
|
+
const all = await client2.list(modelId, { filters, limit: 20 });
|
|
2724
|
+
const keeper = [...all].sort((a, b) => (a.created_at ?? "").localeCompare(b.created_at ?? "") || a.id.localeCompare(b.id))[0];
|
|
2725
|
+
if (keeper && keeper.id !== mine.id) {
|
|
2726
|
+
await client2.remove(modelId, mine.id).catch(() => {
|
|
2727
|
+
});
|
|
2728
|
+
continue;
|
|
2729
|
+
}
|
|
2730
|
+
created.push(scope);
|
|
2731
|
+
}
|
|
2732
|
+
return created;
|
|
2733
|
+
}
|
|
2670
2734
|
|
|
2671
2735
|
// src/tracker.ts
|
|
2672
2736
|
function ticketBranchContext(t) {
|
|
@@ -3036,13 +3100,18 @@ var Tracker = class {
|
|
|
3036
3100
|
/**
|
|
3037
3101
|
* Claim a board-visible lock for `scope` (ISSUE-394) — see board-lock.ts
|
|
3038
3102
|
* for the CAS mechanics. Resolves to `{ ok: true }` immediately, with a
|
|
3039
|
-
* no-op release, when this workspace has no Locks table
|
|
3040
|
-
*
|
|
3041
|
-
*
|
|
3103
|
+
* no-op release, only when this workspace has no Locks table at all. A
|
|
3104
|
+
* Locks table with no row for `scope` is `{ ok: false, reason: 'missing' }`
|
|
3105
|
+
* (CREW-1384): `crew connect` provisions the row.
|
|
3042
3106
|
*/
|
|
3043
3107
|
acquireBoardLock(scope, holderLabel, ttlMs) {
|
|
3044
3108
|
return acquireBoardLock(this.client.records, this.route.resolved?.locksModelId, scope, holderLabel, ttlMs);
|
|
3045
3109
|
}
|
|
3110
|
+
/** The Locks row for `scope` — present? held by whom? Undefined when this workspace has no Locks table. */
|
|
3111
|
+
async lockState(scope) {
|
|
3112
|
+
const model = this.route.resolved?.locksModelId;
|
|
3113
|
+
return model ? lockRowState(this.client.records, model, scope) : void 0;
|
|
3114
|
+
}
|
|
3046
3115
|
/**
|
|
3047
3116
|
* Reports one crew member's live status (ISSUE-325) — "Working" for the
|
|
3048
3117
|
* duration of an agent run, "Idle" once it ends.
|
|
@@ -6673,6 +6742,24 @@ Still failing: ${n.detail ?? n.headline}`, memberId);
|
|
|
6673
6742
|
// src/cli.ts
|
|
6674
6743
|
import { StaleWriteError as StaleWriteError8 } from "@tablation/client";
|
|
6675
6744
|
|
|
6745
|
+
// src/release-ship.ts
|
|
6746
|
+
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
6747
|
+
function releaserFor(release, shipName2) {
|
|
6748
|
+
const named = release.ship;
|
|
6749
|
+
if (!named || release.mode !== "local" && release.mode !== "integrate") return { releases: true, releaser: named ?? null };
|
|
6750
|
+
return { releases: named.trim() === shipName2.trim(), releaser: named };
|
|
6751
|
+
}
|
|
6752
|
+
function nonReleaserNote(repo, releaser) {
|
|
6753
|
+
return `release for ${repo} runs on ${releaser}`;
|
|
6754
|
+
}
|
|
6755
|
+
function missingReleaserWarning(repo, release, ships, now = Date.now()) {
|
|
6756
|
+
if (release.ship || release.mode !== "local" && release.mode !== "integrate") return null;
|
|
6757
|
+
const live = ships.filter((s) => s.last_seen && now - Date.parse(s.last_seen) <= DAY_MS);
|
|
6758
|
+
if (live.length < 2) return null;
|
|
6759
|
+
const names = live.map((s) => s.name ?? "?").join(", ");
|
|
6760
|
+
return `${repo}: release.mode "${release.mode}" with no release.ship, and ${live.length} ships seen in the last day (${names}) \u2014 every one of them will try to release it. Set release.ship in .crew.yaml.`;
|
|
6761
|
+
}
|
|
6762
|
+
|
|
6676
6763
|
// src/watch.ts
|
|
6677
6764
|
import { watch as watchFile, existsSync as existsSync10, statSync as statSync5, openSync as openSync2, readSync, closeSync as closeSync2 } from "fs";
|
|
6678
6765
|
var E = "\x1B[";
|
|
@@ -7455,6 +7542,35 @@ async function patch(o, path, body) {
|
|
|
7455
7542
|
if (!res.ok) throw new ConnectHttpError(res.status, path, res.statusText);
|
|
7456
7543
|
return await res.json();
|
|
7457
7544
|
}
|
|
7545
|
+
async function del(o, path) {
|
|
7546
|
+
const res = await fetch(`${o.baseUrl.replace(/\/+$/, "")}/api${path}`, {
|
|
7547
|
+
method: "DELETE",
|
|
7548
|
+
headers: {
|
|
7549
|
+
Authorization: `Bearer ${o.apiKey}`,
|
|
7550
|
+
"User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0"
|
|
7551
|
+
}
|
|
7552
|
+
});
|
|
7553
|
+
if (!res.ok) throw new ConnectHttpError(res.status, path, res.statusText);
|
|
7554
|
+
}
|
|
7555
|
+
async function provisionReleaseLocks(o, locksModelId, route, repoNames) {
|
|
7556
|
+
const scopes = [...new Set(repoNames)].sort().map((r) => releaseLockScope(route, r));
|
|
7557
|
+
if (scopes.length === 0) return [];
|
|
7558
|
+
const client2 = {
|
|
7559
|
+
// The Locks table is a handful of rows, so read it whole and match in
|
|
7560
|
+
// memory rather than depend on the HTTP filter syntax.
|
|
7561
|
+
list: async (model, params) => {
|
|
7562
|
+
const want = JSON.parse(params?.filters ?? "[]")[0]?.value;
|
|
7563
|
+
const rows = await get(
|
|
7564
|
+
o,
|
|
7565
|
+
`/data-models/${model}/records?limit=500`
|
|
7566
|
+
);
|
|
7567
|
+
return rows.filter((r) => r.scope === want).slice(0, params?.limit ?? rows.length);
|
|
7568
|
+
},
|
|
7569
|
+
create: (model, body) => post2(o, `/data-models/${model}/records`, body),
|
|
7570
|
+
remove: (model, id) => del(o, `/data-models/${model}/records/${id}`)
|
|
7571
|
+
};
|
|
7572
|
+
return ensureLockRows(client2, locksModelId, scopes);
|
|
7573
|
+
}
|
|
7458
7574
|
var ISSUES_TEMPLATE_IDENTIFIER = "crew.issues";
|
|
7459
7575
|
async function listLibraryTemplates(o) {
|
|
7460
7576
|
return get(o, "/library-templates");
|
|
@@ -8325,11 +8441,11 @@ function applyRemoteBranchCleanup(cwd, remote, branches2, dryRun, log) {
|
|
|
8325
8441
|
// src/stream-sweep.ts
|
|
8326
8442
|
import { existsSync as existsSync15, readdirSync as readdirSync3, rmSync as rmSync3, statSync as statSync6 } from "fs";
|
|
8327
8443
|
import { join as join14 } from "path";
|
|
8328
|
-
var
|
|
8444
|
+
var DAY_MS2 = 24 * 60 * 60 * 1e3;
|
|
8329
8445
|
function planStreamSweep(stateDir, retentionDays, now = Date.now()) {
|
|
8330
8446
|
const dir = join14(stateDir, "streams");
|
|
8331
8447
|
if (!existsSync15(dir)) return [];
|
|
8332
|
-
const cutoff = now - retentionDays *
|
|
8448
|
+
const cutoff = now - retentionDays * DAY_MS2;
|
|
8333
8449
|
return readdirSync3(dir).map((name) => join14(dir, name)).filter((path) => {
|
|
8334
8450
|
try {
|
|
8335
8451
|
return statSync6(path).mtimeMs < cutoff;
|
|
@@ -10017,9 +10133,20 @@ ${changed2.map((c) => ` - ${c}
|
|
|
10017
10133
|
shipName: cfg.ship.name
|
|
10018
10134
|
});
|
|
10019
10135
|
}
|
|
10136
|
+
async function releaserLine(tracker, routeName, repo, release) {
|
|
10137
|
+
const who2 = release.ship ? `released by ${release.ship}` : "any ship may release";
|
|
10138
|
+
let lock2;
|
|
10139
|
+
try {
|
|
10140
|
+
const st = await tracker.lockState(releaseLockScope(routeName, repo));
|
|
10141
|
+
lock2 = st === void 0 ? "no Locks table" : !st.exists ? "lock row MISSING (run crew connect)" : st.holder ? `lock held by ${st.holder}` : "lock row free";
|
|
10142
|
+
} catch (e) {
|
|
10143
|
+
lock2 = `lock unreadable (${e.message})`;
|
|
10144
|
+
}
|
|
10145
|
+
return `${who2}; ${lock2}`;
|
|
10146
|
+
}
|
|
10020
10147
|
const unplaceableReported = /* @__PURE__ */ new Set();
|
|
10021
10148
|
async function releasePhase(c, target, opts = {}) {
|
|
10022
|
-
const scope =
|
|
10149
|
+
const scope = releaseLockScope(c.route, target.name);
|
|
10023
10150
|
const remit = emit.forRoute(c.route);
|
|
10024
10151
|
const relLock = dryRun ? void 0 : state.acquire(`release-${scope}`);
|
|
10025
10152
|
if (relLock && !relLock.ok) {
|
|
@@ -10044,11 +10171,22 @@ ${changed2.map((c) => ` - ${c}
|
|
|
10044
10171
|
remit.emit(`${scope}: skipping \u2014 ${detail}`, { step: "release" });
|
|
10045
10172
|
return { scope, tests: "skipped", outcome: "skipped", detail };
|
|
10046
10173
|
}
|
|
10174
|
+
const who2 = releaserFor(repo.release, cfg.ship.name);
|
|
10175
|
+
if (!who2.releases) {
|
|
10176
|
+
const note = nonReleaserNote(target.name, who2.releaser);
|
|
10177
|
+
remit.emit(note, { step: "release" });
|
|
10178
|
+
return { scope, tests: "skipped", outcome: "skipped", detail: note };
|
|
10179
|
+
}
|
|
10047
10180
|
if (!dryRun && !c.enabled) return { scope, tests: "skipped", outcome: "skipped", detail: "route not enabled" };
|
|
10048
10181
|
const tracker = new Tracker(c, cfg.ship);
|
|
10049
10182
|
if (!dryRun) {
|
|
10050
10183
|
const holderLabel = `${cfg.ship.name}:${process.pid}`;
|
|
10051
10184
|
const got = await tracker.acquireBoardLock(scope, holderLabel, RELEASE_LOCK_TTL_MS);
|
|
10185
|
+
if (!got.ok && got.reason === "missing") {
|
|
10186
|
+
const detail = `no Locks row for ${scope} \u2014 run \`crew connect ${c.route}\` to provision it`;
|
|
10187
|
+
remit.warn(`release for ${scope} refused: ${detail}`, { step: "release" });
|
|
10188
|
+
return { scope, tests: "skipped", outcome: "skipped", detail };
|
|
10189
|
+
}
|
|
10052
10190
|
if (!got.ok) {
|
|
10053
10191
|
const why = got.reason === "held" ? `held by ${got.heldBy ?? "another ship"}` : "claimed by another ship mid-check";
|
|
10054
10192
|
remit.emit(`release for ${scope} is ${why} on the board \u2014 skipping`, { step: "release" });
|
|
@@ -11278,6 +11416,18 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
11278
11416
|
}
|
|
11279
11417
|
const route2 = `${found.workspaceSlug}/${found.projectSlug ?? found.projectId}`;
|
|
11280
11418
|
const resolvedPath = resolvedPathFor(cfg.ship.stateDir, route2);
|
|
11419
|
+
if (found.locksModelId && !dryRun) {
|
|
11420
|
+
const configured = cfg.routes.find((r) => r.route === route2);
|
|
11421
|
+
const repoNames = [...Object.values(found.repoNames ?? {}), ...configured ? reposOf(configured).map((t) => t.name) : []].filter((n) => !!n);
|
|
11422
|
+
try {
|
|
11423
|
+
const made = await provisionReleaseLocks(authOpts, found.locksModelId, route2, repoNames);
|
|
11424
|
+
for (const sc of made) process.stderr.write(`created release lock row ${sc}
|
|
11425
|
+
`);
|
|
11426
|
+
} catch (e) {
|
|
11427
|
+
process.stderr.write(`(could not provision release lock rows \u2014 ${e.message})
|
|
11428
|
+
`);
|
|
11429
|
+
}
|
|
11430
|
+
}
|
|
11281
11431
|
let operator = found.operator;
|
|
11282
11432
|
if (!operator && found.holds.length > 0 && process.stdin.isTTY && process.stdout.isTTY) {
|
|
11283
11433
|
operator = await pickOperator(found.holds);
|
|
@@ -11399,6 +11549,13 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
11399
11549
|
process.stderr.write(`${ensureClaudeMcp({ bin: cfg.ship.agent.bin, url: mcpUrlFor(baseUrl), key: apiKey, replace, dryRun })}
|
|
11400
11550
|
`);
|
|
11401
11551
|
}
|
|
11552
|
+
if (found.holds.length) {
|
|
11553
|
+
process.stderr.write(
|
|
11554
|
+
`
|
|
11555
|
+
Holds resolved (${found.holds.length}): ${found.holds.map((h) => `${h.name || h.id}${h.id === operator ? " [operator]" : ""}`).join(", ")}
|
|
11556
|
+
`
|
|
11557
|
+
);
|
|
11558
|
+
}
|
|
11402
11559
|
if (found.provisioning.length) {
|
|
11403
11560
|
process.stderr.write(`
|
|
11404
11561
|
Provisioned for this machine (${ship.name}):
|
|
@@ -11949,6 +12106,8 @@ watermark: ${state.watermark()}
|
|
|
11949
12106
|
for (const r of await resolvedRepos(route)) {
|
|
11950
12107
|
const bh = baseHealth(r);
|
|
11951
12108
|
if (bh.unsafe) process.stdout.write(`base: ${r.name} \u2014 UNSAFE, no worktree cuts or releases: ${bh.detail}
|
|
12109
|
+
`);
|
|
12110
|
+
process.stdout.write(`releaser: ${r.name} \u2014 ${await releaserLine(statusTracker, route.route, r.name, r.config.release)}
|
|
11952
12111
|
`);
|
|
11953
12112
|
}
|
|
11954
12113
|
if (waiting) {
|
|
@@ -12009,6 +12168,14 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
|
|
|
12009
12168
|
`);
|
|
12010
12169
|
if (bh.unsafe) process.exitCode = 1;
|
|
12011
12170
|
}
|
|
12171
|
+
if (config.release.ship) {
|
|
12172
|
+
process.stdout.write(` release.ship ${config.release.ship}${config.release.ship === cfg.ship.name ? " (this ship)" : ""}
|
|
12173
|
+
`);
|
|
12174
|
+
} else {
|
|
12175
|
+
const warn = missingReleaserWarning(r.name, config.release, await tracker.shipRows().catch(() => []));
|
|
12176
|
+
if (warn) process.stdout.write(` WARNING ${warn}
|
|
12177
|
+
`);
|
|
12178
|
+
}
|
|
12012
12179
|
}
|
|
12013
12180
|
const missingAuthorship = await tracker.missingAuthorshipColumns();
|
|
12014
12181
|
process.stdout.write(
|
|
@@ -12042,6 +12209,31 @@ crew manifest: ${seats2.length ? seats2.map((c) => c.name).join(", ") : "non
|
|
|
12042
12209
|
);
|
|
12043
12210
|
}
|
|
12044
12211
|
}
|
|
12212
|
+
{
|
|
12213
|
+
const op = route.resolved?.operator;
|
|
12214
|
+
const row = op ? crewRows.find((c) => c.id === op) : void 0;
|
|
12215
|
+
if (!op) {
|
|
12216
|
+
process.stdout.write("operator identity: no operator on this route \u2014 run `crew connect`\n");
|
|
12217
|
+
} else if (!row) {
|
|
12218
|
+
process.stdout.write(`operator identity: ERROR operator ${op} is not a row of the Crew table
|
|
12219
|
+
`);
|
|
12220
|
+
process.exitCode = 1;
|
|
12221
|
+
} else if (!row.user_id) {
|
|
12222
|
+
process.stdout.write(
|
|
12223
|
+
`operator identity: ERROR Crew row "${row.name ?? op}" has no user_id \u2014 their UI comments will read "(no identity)"; link it to the account they sign in with
|
|
12224
|
+
`
|
|
12225
|
+
);
|
|
12226
|
+
process.exitCode = 1;
|
|
12227
|
+
} else {
|
|
12228
|
+
const resolves = memberByIdentity(buildRoster(configuredMembers(route), crewRows), row.user_id);
|
|
12229
|
+
process.stdout.write(
|
|
12230
|
+
resolves ? `operator identity: comments from ${row.name ?? op} resolve to ${crewLabel(resolves)}
|
|
12231
|
+
` : `operator identity: ERROR user_id ${row.user_id} does not resolve through the roster
|
|
12232
|
+
`
|
|
12233
|
+
);
|
|
12234
|
+
if (!resolves) process.exitCode = 1;
|
|
12235
|
+
}
|
|
12236
|
+
}
|
|
12045
12237
|
if (ships.length > 0) {
|
|
12046
12238
|
try {
|
|
12047
12239
|
const humanHolds = new Set(holdIds(buildRoster(configuredMembers(route), crewRows)));
|
package/package.json
CHANGED
|
@@ -158,7 +158,11 @@ Comments (filter Comments by `ticket_id`) and check for anything from a hold
|
|
|
158
158
|
since your last comment: an answer to a question, new direction, or a
|
|
159
159
|
"verified"/"looks good" that implies next steps. Respond substantively:
|
|
160
160
|
- If a hold answered a blocking question on a `needs_info` ticket, resume
|
|
161
|
-
work (see Step 2) and move status back to `in_progress`.
|
|
161
|
+
work (see Step 2) and move status back to `in_progress`. The comment
|
|
162
|
+
itself is the answer: the operator does not also have to flip the status
|
|
163
|
+
to `in_progress` — you do that yourself when you resume (`needs_planning`
|
|
164
|
+
stays as it is; only the operator clears it). Do not park the ticket
|
|
165
|
+
again because the status was left alone.
|
|
162
166
|
- If a hold gave new direction on an `in_progress` ticket, adjust the
|
|
163
167
|
in-progress branch accordingly and post a comment on what changed.
|
|
164
168
|
- If an `in_progress` ticket has no assignee and no new comment either,
|
|
@@ -153,7 +153,11 @@ Comments (filter Comments by `ticket_id`) and check for anything from a hold
|
|
|
153
153
|
since your last comment: an answer to a question, new direction, or a
|
|
154
154
|
"verified"/"looks good" that implies next steps. Respond substantively:
|
|
155
155
|
- If a hold answered a blocking question on a `needs_info` ticket, resume
|
|
156
|
-
work (see Step 2) and move status back to `in_progress`.
|
|
156
|
+
work (see Step 2) and move status back to `in_progress`. The comment
|
|
157
|
+
itself is the answer: the operator does not also have to flip the status
|
|
158
|
+
to `in_progress` — you do that yourself when you resume (`needs_planning`
|
|
159
|
+
stays as it is; only the operator clears it). Do not park the ticket
|
|
160
|
+
again because the status was left alone.
|
|
157
161
|
- If a hold gave new direction on an `in_progress` ticket, adjust the
|
|
158
162
|
in-progress branch accordingly and post a comment on what changed.
|
|
159
163
|
- If an `in_progress` ticket has no assignee and no new comment either,
|