@tablation/crew 0.54.3 → 0.55.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.
Files changed (2) hide show
  1. package/dist/cli.js +185 -38
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -3283,6 +3283,7 @@ var DEFAULT_CONTRACT = {
3283
3283
  author: "filed_by_id",
3284
3284
  ship: "ship_id",
3285
3285
  heldBy: "held_by_ship_id",
3286
+ resolutionNote: "resolution_note",
3286
3287
  verifiedSha: "verified_sha",
3287
3288
  prRef: "pr_ref",
3288
3289
  position: "position"
@@ -3404,6 +3405,69 @@ function validateContract(c) {
3404
3405
  }
3405
3406
  var EVENT_LOG_MISSING = "the Issues table has no event log \u2014 crew writes its audit trail there (CREW-1574). In the app, open the Issues table's Data & lifecycle tab and turn on Event log, then rerun.";
3406
3407
 
3408
+ // src/callout.ts
3409
+ var calloutHeader = (kind, label) => `> [!${kind}]
3410
+ > **Crew** \u2014 ${label}`;
3411
+ var calloutLabelLine = (label) => `> **Crew** \u2014 ${label}`;
3412
+
3413
+ // src/gate-red.ts
3414
+ var GATE_RED_MARKER = calloutHeader("CAUTION", "Test Gate Red");
3415
+ var shaLine = (g) => `Head \`${g.sha.slice(0, 8)}\`.`;
3416
+ var GATE_RED_AFTER = 3;
3417
+ var lastLines = (output, n) => output.replace(/\u001b\[[0-9;]*m/g, "").trimEnd().split("\n").slice(-n).join("\n");
3418
+ function gateRedTickets(merged, merges) {
3419
+ const seen = /* @__PURE__ */ new Set();
3420
+ const out = [];
3421
+ for (const c of [...merged, ...merges.filter((m) => m.skipReason === "already-merged")]) {
3422
+ if (seen.has(c.ticket.id)) continue;
3423
+ seen.add(c.ticket.id);
3424
+ out.push(c.ticket);
3425
+ }
3426
+ return out;
3427
+ }
3428
+ function gateRedComment(g) {
3429
+ return `${GATE_RED_MARKER}
3430
+ > ${shaLine(g)}
3431
+
3432
+ **This ticket is merged on the base, but the release test gate is red \u2014 nothing has shipped.**
3433
+
3434
+ The \`test\` hook (\`${g.hook}\`) has failed ${g.count} cycles running on the same head (\`${g.sha.slice(0, 8)}\`). The ticket's commit is on the base and will ship once the gate passes; it is not stranded, and re-setting it to \`verified\` will not help. Last lines of the hook's output:
3435
+
3436
+ \`\`\`
3437
+ ${g.tail}
3438
+ \`\`\`
3439
+ `;
3440
+ }
3441
+ var alreadyReported = (ticket, g, events) => events.some((e) => e.record_id === ticket.id && e.event_type === "gate_red" && (e.message ?? "").includes(shaLine(g)));
3442
+
3443
+ // src/test-evidence.ts
3444
+ var MAX_FAIL_LINES = 20;
3445
+ var ANSI = /\u001b\[[0-9;]*m/g;
3446
+ var FAIL_LINE = /^\s*(FAIL\b|●\s|not ok\b)/;
3447
+ var ERROR_LINE = /\b(?:[A-Z]\w*)?Error\b[:\s]/;
3448
+ function failureSummary(output, tailLines = 15) {
3449
+ const lines = output.replace(ANSI, "").trimEnd().split("\n");
3450
+ const names = [];
3451
+ for (const l of lines) {
3452
+ if (FAIL_LINE.test(l) && !names.includes(l.trim())) names.push(l.trim());
3453
+ if (names.length >= MAX_FAIL_LINES) break;
3454
+ }
3455
+ const at = lines.findIndex((l) => ERROR_LINE.test(l));
3456
+ if (names.length === 0 && at < 0) return lastLines(output, tailLines);
3457
+ const parts = [...names];
3458
+ if (at >= 0) parts.push("", ...lines.slice(at, at + 4).map((l) => l.trimEnd()));
3459
+ return parts.join("\n").trim();
3460
+ }
3461
+ var headLine = (head) => `Candidate head \`${head.slice(0, 8)}\`.`;
3462
+ function describeComposition(c) {
3463
+ const rows = c.branches.length ? c.branches.map((b) => `- ${b.key} \`${b.branch}\` @ \`${b.tip.slice(0, 8)}\``).join("\n") : "- (none merged this cycle)";
3464
+ return `${headLine(c.head)} Base before merging \`${c.base.slice(0, 8)}\`. Merged branches:
3465
+
3466
+ ${rows}
3467
+
3468
+ The gate runs on the base plus every verified branch above, not on the base alone: reproduce on that composition.`;
3469
+ }
3470
+
3407
3471
  // src/shell.ts
3408
3472
  import { spawn } from "child_process";
3409
3473
  import { platform } from "process";
@@ -3515,7 +3579,13 @@ function describeRelease(o, route) {
3515
3579
  return {
3516
3580
  level: "fail",
3517
3581
  headline: `${route}: release blocked \u2014 tests failed`,
3518
- detail: "the target stays on the previous release"
3582
+ detail: withHookTail(
3583
+ o.composition ? `the target stays on the previous release
3584
+
3585
+ ${describeComposition(o.composition)}` : "the target stays on the previous release",
3586
+ o.hookFailure
3587
+ ),
3588
+ ...o.composition ? { head: o.composition.head } : {}
3519
3589
  };
3520
3590
  }
3521
3591
  if (o.stopped === "setup failed") {
@@ -3554,6 +3624,30 @@ function describeRelease(o, route) {
3554
3624
  }
3555
3625
  return null;
3556
3626
  }
3627
+ function recoveredFailureHeadlines(o, route) {
3628
+ const setup = `${route}: release blocked \u2014 setup failed`;
3629
+ const tests = `${route}: release blocked \u2014 tests failed`;
3630
+ const build = `${route}: build FAILED`;
3631
+ const deploy = `${route}: deploy FAILED`;
3632
+ if (o.deployed || o.integrated) {
3633
+ return [
3634
+ setup,
3635
+ tests,
3636
+ build,
3637
+ deploy,
3638
+ `${route}: release blocked \u2014 checkout is not on the base branch`,
3639
+ `${route}: release blocked \u2014 working tree is not clean`
3640
+ ];
3641
+ }
3642
+ switch (o.stopped) {
3643
+ case "build failed":
3644
+ case "deploy failed":
3645
+ case "tag push failed":
3646
+ return o.stopped === "deploy failed" ? [setup, tests, build] : [setup, tests];
3647
+ default:
3648
+ return [];
3649
+ }
3650
+ }
3557
3651
 
3558
3652
  // src/install.ts
3559
3653
  import { dirname as dirname4, join as join9, extname } from "path";
@@ -6497,11 +6591,6 @@ function materializeBranchForTicket(dir, cfg, t, role, onProblem) {
6497
6591
  import { writeFileSync as writeFileSync7 } from "fs";
6498
6592
  import { join as join11 } from "path";
6499
6593
 
6500
- // src/callout.ts
6501
- var calloutHeader = (kind, label) => `> [!${kind}]
6502
- > **Crew** \u2014 ${label}`;
6503
- var calloutLabelLine = (label) => `> **Crew** \u2014 ${label}`;
6504
-
6505
6594
  // src/stalled.ts
6506
6595
  var STALLED_AFTER_CYCLES = 30;
6507
6596
  var CYCLE_MS = 120 * 1e3;
@@ -7229,36 +7318,6 @@ async function applyStrandedVerified(writer, s, contract, operatorId, memberId,
7229
7318
  }
7230
7319
  }
7231
7320
 
7232
- // src/gate-red.ts
7233
- var GATE_RED_MARKER = calloutHeader("CAUTION", "Test Gate Red");
7234
- var shaLine = (g) => `Head \`${g.sha.slice(0, 8)}\`.`;
7235
- var GATE_RED_AFTER = 3;
7236
- var lastLines = (output, n) => output.replace(/\u001b\[[0-9;]*m/g, "").trimEnd().split("\n").slice(-n).join("\n");
7237
- function gateRedTickets(merged, merges) {
7238
- const seen = /* @__PURE__ */ new Set();
7239
- const out = [];
7240
- for (const c of [...merged, ...merges.filter((m) => m.skipReason === "already-merged")]) {
7241
- if (seen.has(c.ticket.id)) continue;
7242
- seen.add(c.ticket.id);
7243
- out.push(c.ticket);
7244
- }
7245
- return out;
7246
- }
7247
- function gateRedComment(g) {
7248
- return `${GATE_RED_MARKER}
7249
- > ${shaLine(g)}
7250
-
7251
- **This ticket is merged on the base, but the release test gate is red \u2014 nothing has shipped.**
7252
-
7253
- The \`test\` hook (\`${g.hook}\`) has failed ${g.count} cycles running on the same head (\`${g.sha.slice(0, 8)}\`). The ticket's commit is on the base and will ship once the gate passes; it is not stranded, and re-setting it to \`verified\` will not help. Last lines of the hook's output:
7254
-
7255
- \`\`\`
7256
- ${g.tail}
7257
- \`\`\`
7258
- `;
7259
- }
7260
- var alreadyReported = (ticket, g, events) => events.some((e) => e.record_id === ticket.id && e.event_type === "gate_red" && (e.message ?? "").includes(shaLine(g)));
7261
-
7262
7321
  // src/agent.ts
7263
7322
  import { spawn as spawn2, spawnSync as spawnSync5 } from "child_process";
7264
7323
  import { readFileSync as readFileSync12, existsSync as existsSync14, statSync as statSync5, mkdirSync as mkdirSync6, writeFileSync as writeFileSync8, createWriteStream, openSync as openSync3, closeSync as closeSync3, readSync } from "fs";
@@ -10693,7 +10752,21 @@ async function mergeAndRelease(o, decision, fresh, releaseBranch, isolated = fal
10693
10752
  const sha = headSha(o.cwd);
10694
10753
  const count = o.state?.noteTestGateFailed?.(sha) ?? 1;
10695
10754
  const gateRed = count >= GATE_RED_AFTER && !o.state?.testGateReported?.(sha) ? { sha, count, hook: hookLabel(o.repo, "test"), tail: lastLines(r.output, 15) } : void 0;
10696
- return { merged, conflicts, unbuildable, deployed: false, stopped: "tests failed", decision, gateRed };
10755
+ const composition = {
10756
+ head: sha,
10757
+ base: preMerge,
10758
+ branches: merged.filter((c) => c.branch).map((c) => ({
10759
+ key: c.ticket.issue_tag ?? c.ticket.issue_id,
10760
+ branch: c.branch,
10761
+ tip: resolve2(o.cwd, c.branch) ?? ""
10762
+ }))
10763
+ };
10764
+ const hookFailure2 = {
10765
+ hook: "test",
10766
+ label: hookLabel(o.repo, "test"),
10767
+ tail: failureSummary(r.output, HOOK_FAILURE_TAIL_LINES)
10768
+ };
10769
+ return { merged, conflicts, unbuildable, deployed: false, stopped: "tests failed", decision, gateRed, hookFailure: hookFailure2, composition };
10697
10770
  }
10698
10771
  o.state?.clearTestGateFailed?.();
10699
10772
  }
@@ -10865,9 +10938,15 @@ async function applyFailureAlert(writer, n, route, openTickets, memberId, log, d
10865
10938
  return { kind: "commented", issueId: label };
10866
10939
  }
10867
10940
  try {
10941
+ let body = n.detail ?? n.headline;
10942
+ if (n.head && writer.events) {
10943
+ const line = headLine(n.head);
10944
+ const shown = (await writer.events("failure_alert")).some((e) => e.record_id === existing.id && (e.message ?? "").includes(line));
10945
+ if (shown) body = `${n.headline} \u2014 ${line}`;
10946
+ }
10868
10947
  await writer.postEvent(existing.id, `${FAILURE_ALERT_MARKER}
10869
10948
 
10870
- Still failing: ${n.detail ?? n.headline}`, memberId, "failure_alert");
10949
+ Still failing: ${body}`, memberId, "failure_alert");
10871
10950
  log.warn(`release failure recurring \u2014 commented on ${label}`, { step: "release" });
10872
10951
  return { kind: "commented", issueId: label };
10873
10952
  } catch (e) {
@@ -10889,12 +10968,64 @@ Still failing: ${n.detail ?? n.headline}`, memberId, "failure_alert");
10889
10968
  ...placement.project_id ? { project_id: placement.project_id } : {}
10890
10969
  }, memberId);
10891
10970
  const label = created.issue_id ? displayKey(created) : created.id;
10971
+ if (n.head) {
10972
+ await writer.postEvent(created.id, `${FAILURE_ALERT_MARKER}
10973
+
10974
+ Filed with the evidence above. ${headLine(n.head)}`, memberId, "failure_alert").catch(() => {
10975
+ });
10976
+ }
10892
10977
  log.warn(`release failure \u2014 filed ${label}`, { step: "release" });
10893
10978
  return { kind: "filed", issueId: label };
10894
10979
  } catch (e) {
10895
10980
  return { kind: "failed", why: e.message };
10896
10981
  }
10897
10982
  }
10983
+ function alertResolvedStatus(c) {
10984
+ return c.statuses.resolved.find((s) => s === "closed_completed");
10985
+ }
10986
+ async function applyFailureRecovery(writer, headlines, openTickets, contract, memberId, passed, log, dryRun) {
10987
+ const out = { closed: [], noted: [], failed: [] };
10988
+ const closedStatus = alertResolvedStatus(contract);
10989
+ const closable = /* @__PURE__ */ new Set(["new", contract.statuses.approved, contract.statuses.needsHuman]);
10990
+ for (const headline of headlines) {
10991
+ const alert = findOpenFailureAlert(openTickets, headline);
10992
+ if (!alert) continue;
10993
+ const label = displayKey(alert);
10994
+ const willClose = closedStatus !== void 0 && closable.has(alert.status);
10995
+ if (dryRun) {
10996
+ log.emit(`would ${willClose ? "close" : "note on"} ${label} \u2014 ${passed}`, { step: "release" });
10997
+ continue;
10998
+ }
10999
+ try {
11000
+ await writer.postEvent(
11001
+ alert.id,
11002
+ `${FAILURE_ALERT_MARKER}
11003
+
11004
+ Passed: ${passed}.${willClose ? " Closing this alert." : ""}`,
11005
+ memberId,
11006
+ "failure_alert"
11007
+ );
11008
+ if (willClose) {
11009
+ const col = contract.columns;
11010
+ await writer.updateTicket(alert.id, {
11011
+ [contract.columns.status]: closedStatus,
11012
+ [col.resolutionNote]: `Resolved automatically: ${passed}.`,
11013
+ [col.assignee]: null,
11014
+ [col.heldBy]: null
11015
+ });
11016
+ out.closed.push(label);
11017
+ log.emit(`${label} closed \u2014 ${passed}`, { step: "release" });
11018
+ } else {
11019
+ out.noted.push(label);
11020
+ log.emit(`${label} left open \u2014 ${passed}`, { step: "release" });
11021
+ }
11022
+ } catch (e) {
11023
+ out.failed.push(label);
11024
+ log.warn(`could not resolve failure alert ${label}: ${e.message}`, { step: "release" });
11025
+ }
11026
+ }
11027
+ return out;
11028
+ }
10898
11029
 
10899
11030
  // src/projects-cmd.ts
10900
11031
  async function buildProjectsReport(routes, open) {
@@ -14792,6 +14923,22 @@ ${changed2.map((c) => ` - ${c}
14792
14923
  });
14793
14924
  }
14794
14925
  }
14926
+ const recovered = recoveredFailureHeadlines(outcome, scope);
14927
+ if (recovered.length) {
14928
+ const passed = outcome.version ? `${scope} released ${outcome.version}` : outcome.deployed || outcome.integrated ? `${scope} release passed` : `${scope} release got past this stage`;
14929
+ await applyFailureRecovery(
14930
+ tracker,
14931
+ recovered,
14932
+ all,
14933
+ tracker.contract,
14934
+ releaseSeat(c),
14935
+ passed,
14936
+ remit,
14937
+ dryRun
14938
+ ).catch((e) => {
14939
+ remit.warn(`could not resolve failure tickets: ${e.message}`, { step: "release" });
14940
+ });
14941
+ }
14795
14942
  if (outcome.buildCulprits?.length) {
14796
14943
  const seat = releaseSeat(c);
14797
14944
  for (const cu of outcome.buildCulprits) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.54.3",
3
+ "version": "0.55.0",
4
4
  "description": "A standing team of headless agents that picks work off a Tablation board, does it on a machine you control, and reports back.",
5
5
  "type": "module",
6
6
  "license": "MIT",