@tablation/crew 0.3.0 → 0.4.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 +124 -6
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1328,6 +1328,8 @@ var State = class _State {
1328
1328
  const suffix = _State.safe(route);
1329
1329
  const blocked = `.release-blocked-${suffix}`;
1330
1330
  const failed = `.deploy-failed-sha-${suffix}`;
1331
+ const gateFailed = `.test-gate-failed-${suffix}`;
1332
+ const gateReported = `.test-gate-reported-${suffix}`;
1331
1333
  const read = (f) => {
1332
1334
  try {
1333
1335
  return readFileSync3(this.path(f), "utf8").trim() || null;
@@ -1361,6 +1363,27 @@ var State = class _State {
1361
1363
  },
1362
1364
  clearDeployFailed: () => {
1363
1365
  rmSync(this.path(failed), { force: true });
1366
+ },
1367
+ /**
1368
+ * Consecutive test-gate failures on the same head (CREW-1368). A failure
1369
+ * on a new head restarts the count. Held on disk because one process is
1370
+ * one cycle, and "failed three cycles running" only exists across them.
1371
+ */
1372
+ noteTestGateFailed: (sha) => {
1373
+ const [prev, n] = (read(gateFailed) ?? "").split(" ");
1374
+ const next = prev === sha ? (Number.parseInt(n ?? "", 10) || 0) + 1 : 1;
1375
+ writeFileSync(this.path(gateFailed), `${sha} ${next}
1376
+ `);
1377
+ return next;
1378
+ },
1379
+ clearTestGateFailed: () => {
1380
+ rmSync(this.path(gateFailed), { force: true });
1381
+ },
1382
+ /** Whether the board was already told about this head's red gate. */
1383
+ testGateReported: (sha) => read(gateReported) === sha,
1384
+ noteTestGateReported: (sha) => {
1385
+ writeFileSync(this.path(gateReported), `${sha}
1386
+ `);
1364
1387
  }
1365
1388
  };
1366
1389
  }
@@ -2869,6 +2892,10 @@ var countCommits = (cwd, range) => Number.parseInt(gitOk(cwd, ["rev-list", "--co
2869
2892
  function latestReleaseTag(cwd, pattern = "v*", ref = "HEAD") {
2870
2893
  return gitOk(cwd, ["describe", "--tags", "--abbrev=0", "--match", pattern, ref]);
2871
2894
  }
2895
+ function firstReleaseTagContaining(cwd, sha, pattern = "v*") {
2896
+ const out = gitOk(cwd, ["tag", "--contains", sha, "--list", pattern, "--sort=creatordate"]);
2897
+ return out?.split("\n").map((l) => l.trim()).find(Boolean) ?? null;
2898
+ }
2872
2899
  var tagCommit = (cwd, tag) => resolve2(cwd, tag);
2873
2900
  function createReleaseTag(cwd, tag, sha, message) {
2874
2901
  git(cwd, ["tag", "-a", tag, sha, "-m", message]);
@@ -3579,7 +3606,7 @@ function flagComment(s) {
3579
3606
  return `${STRANDED_MARKER}
3580
3607
  **Verified, but nothing to merge \u2014 flagging before this repeats forever.**
3581
3608
 
3582
- \`${key}\` is \`verified\` with no branch on this ship, and its key names no commit on \`${s.base}\` (searched \`${s.remote}/${s.base}\`'s history for a bounded \`${key}\` in a commit subject or body). One of three things is true: it landed under a commit that never named the key, the branch was deleted before it merged, or it was verified in error \u2014 the crew cannot tell these apart and will not guess.
3609
+ \`${key}\` is \`verified\` with no branch on this ship, and its key names no commit anywhere in this ship's \`${s.base}\` history (searched all of it, local commits included, for a bounded \`${key}\` in a commit subject or body). One of three things is true: it landed under a commit that never named the key, the branch was deleted before it merged, or it was verified in error \u2014 the crew cannot tell these apart and will not guess.
3583
3610
 
3584
3611
  If it is still unplaceable next cycle, this ticket is escalated to a person. If the work already landed, closing this ticket by hand now avoids that.`;
3585
3612
  }
@@ -3588,7 +3615,7 @@ function escalateComment2(s) {
3588
3615
  return `${STRANDED_MARKER}
3589
3616
  **Still nothing to merge \u2014 stopping rather than repeating.**
3590
3617
 
3591
- \`${key}\` was flagged as stranded at \`verified\` on an earlier cycle and still has no branch on this ship or on \`${s.remote}\` and no commit on \`${s.base}\` naming its key (searched \`${s.remote}/${s.base}\`). This needs a person: either the work landed under a commit that doesn't name the key and this ticket can simply close, or the branch is gone and the work needs redoing.
3618
+ \`${key}\` was flagged as stranded at \`verified\` on an earlier cycle and still has no branch on this ship or on \`${s.remote}\` and no commit in this ship's \`${s.base}\` history naming its key (searched all of it, local commits included). This needs a person: either the work landed under a commit that doesn't name the key and this ticket can simply close, or the branch is gone and the work needs redoing.
3592
3619
 
3593
3620
  The crew will not close this on its own.`;
3594
3621
  }
@@ -3632,6 +3659,33 @@ async function applyStrandedVerified(writer, s, contract, operatorId, memberId,
3632
3659
  }
3633
3660
  }
3634
3661
 
3662
+ // src/gate-red.ts
3663
+ var GATE_RED_MARKER = "<!-- crew:test-gate-red";
3664
+ var GATE_RED_AFTER = 3;
3665
+ var lastLines = (output, n) => output.replace(/\u001b\[[0-9;]*m/g, "").trimEnd().split("\n").slice(-n).join("\n");
3666
+ function gateRedTickets(merged, merges) {
3667
+ const seen = /* @__PURE__ */ new Set();
3668
+ const out = [];
3669
+ for (const c of [...merged, ...merges.filter((m) => m.skipReason === "already-merged")]) {
3670
+ if (seen.has(c.ticket.id)) continue;
3671
+ seen.add(c.ticket.id);
3672
+ out.push(c.ticket);
3673
+ }
3674
+ return out;
3675
+ }
3676
+ function gateRedComment(g) {
3677
+ return `${GATE_RED_MARKER} ${g.sha.slice(0, 8)} -->
3678
+ **This ticket is merged on the base, but the release test gate is red \u2014 nothing has shipped.**
3679
+
3680
+ 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:
3681
+
3682
+ \`\`\`
3683
+ ${g.tail}
3684
+ \`\`\`
3685
+ `;
3686
+ }
3687
+ var alreadyReported = (ticket, g, comments) => comments.some((c) => c.ticket_id === ticket.id && (c.body ?? "").includes(`${GATE_RED_MARKER} ${g.sha.slice(0, 8)}`));
3688
+
3635
3689
  // src/agent.ts
3636
3690
  import { spawn } from "child_process";
3637
3691
  import { readFileSync as readFileSync5, existsSync as existsSync6, statSync as statSync2, mkdirSync as mkdirSync4, writeFileSync as writeFileSync3, createWriteStream } from "fs";
@@ -4888,7 +4942,32 @@ function planMerge(cwd, tickets, contract, lastReleased, branchFor = (t) => bran
4888
4942
  return verified.map((ticket) => {
4889
4943
  const branch = branchFor(ticket);
4890
4944
  if (!branch) {
4891
- const onMain = lastReleased ? findKeyInRange(cwd, referenceKeys(ticket), `${lastReleased}..HEAD`) !== null : false;
4945
+ const keys = referenceKeys(ticket);
4946
+ const unreleased = lastReleased ? findKeyInRange(cwd, keys, `${lastReleased}..HEAD`) : null;
4947
+ if (unreleased) {
4948
+ return {
4949
+ ticket,
4950
+ branch: null,
4951
+ entries: [],
4952
+ usedFallback: false,
4953
+ bump: "patch",
4954
+ majorRequested: false,
4955
+ skipReason: "already-merged"
4956
+ };
4957
+ }
4958
+ const anywhere = findKeyInRange(cwd, keys, "HEAD");
4959
+ if (anywhere) {
4960
+ return {
4961
+ ticket,
4962
+ branch: null,
4963
+ entries: [],
4964
+ usedFallback: false,
4965
+ bump: "patch",
4966
+ majorRequested: false,
4967
+ skipReason: lastReleased ? "already-released" : "already-merged",
4968
+ mergedSha: anywhere
4969
+ };
4970
+ }
4892
4971
  return {
4893
4972
  ticket,
4894
4973
  branch: null,
@@ -4896,7 +4975,7 @@ function planMerge(cwd, tickets, contract, lastReleased, branchFor = (t) => bran
4896
4975
  usedFallback: false,
4897
4976
  bump: "patch",
4898
4977
  majorRequested: false,
4899
- skipReason: onMain ? "already-merged" : "never-built"
4978
+ skipReason: "never-built"
4900
4979
  };
4901
4980
  }
4902
4981
  const squash = findSquashOfBranch(
@@ -5465,7 +5544,12 @@ async function runRelease(o) {
5465
5544
  let applied = 0;
5466
5545
  for (const c of decision.merges) {
5467
5546
  if (!c.branch || c.mergedSha) {
5468
- if (c.skipReason === "already-merged") {
5547
+ if (c.skipReason === "already-released") {
5548
+ o.emit.emit(
5549
+ `verified and already released (on the base as ${c.mergedSha?.slice(0, 7)}), waiting to be stamped`,
5550
+ { ticket: c.ticket.issue_id }
5551
+ );
5552
+ } else if (c.skipReason === "already-merged") {
5469
5553
  o.emit.emit(
5470
5554
  `verified and already merged${c.mergedSha ? ` (on the base as ${c.mergedSha.slice(0, 7)})` : ""}, waiting on a successful release`,
5471
5555
  { ticket: c.ticket.issue_id }
@@ -5570,8 +5654,12 @@ async function runRelease(o) {
5570
5654
  }
5571
5655
  if (r && r.code !== 0) {
5572
5656
  o.emit.error(`test gate FAILED \u2014 not deploying; the target stays on the previous release`);
5573
- return { merged, conflicts, unbuildable, deployed: false, stopped: "tests failed", decision };
5657
+ const sha = headSha(o.cwd);
5658
+ const count = o.state?.noteTestGateFailed?.(sha) ?? 1;
5659
+ const gateRed = count >= GATE_RED_AFTER && !o.state?.testGateReported?.(sha) ? { sha, count, hook: hookLabel(o.repo, "test"), tail: lastLines(r.output, 15) } : void 0;
5660
+ return { merged, conflicts, unbuildable, deployed: false, stopped: "tests failed", decision, gateRed };
5574
5661
  }
5662
+ o.state?.clearTestGateFailed?.();
5575
5663
  }
5576
5664
  }
5577
5665
  const size = merged.some((m) => m.bump === "minor") ? "minor" : "patch";
@@ -8983,6 +9071,36 @@ ${changed2.map((c) => ` - ${c}
8983
9071
  } else if (outcome.stopped) {
8984
9072
  remit.emit(`nothing stamped \u2014 ${outcome.stopped}`);
8985
9073
  }
9074
+ for (const m of outcome.decision.merges) {
9075
+ if (m.skipReason !== "already-released" || !m.mergedSha) continue;
9076
+ const tag = firstReleaseTagContaining(target.dir, m.mergedSha, repo.release.tagPattern ?? "v*");
9077
+ if (!tag) continue;
9078
+ remit.enter("reconcile");
9079
+ await applyStamp(
9080
+ tracker,
9081
+ [{ ticket: m.ticket, reason: `carried by ${tag}`, sha: m.mergedSha }],
9082
+ tag.replace(/^v/, ""),
9083
+ tracker.contract,
9084
+ remit,
9085
+ dryRun,
9086
+ target.dir
9087
+ );
9088
+ }
9089
+ if (outcome.gateRed && !dryRun) {
9090
+ try {
9091
+ const g = outcome.gateRed;
9092
+ const comments = await tracker.comments();
9093
+ const seat = c.resolved?.seats.dev ?? c.resolved?.seats.qa ?? "";
9094
+ for (const t of gateRedTickets(outcome.merged, outcome.decision.merges)) {
9095
+ if (alreadyReported(t, g, comments)) continue;
9096
+ await tracker.postEvent(t.id, gateRedComment(g), seat);
9097
+ remit.warn(`test gate red for ${g.count} cycles \u2014 noted on the ticket`, { ticket: t.issue_id, step: "merge" });
9098
+ }
9099
+ state.release(scope).noteTestGateReported(g.sha);
9100
+ } catch (e) {
9101
+ remit.warn(`could not note the red test gate on the board: ${e.message}`, { step: "merge" });
9102
+ }
9103
+ }
8986
9104
  if (outcome.externalClosures?.length) {
8987
9105
  await applyExternalClosures(tracker, outcome.externalClosures, tracker.contract, remit, dryRun, target.dir);
8988
9106
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.3.0",
3
+ "version": "0.4.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",