@tablation/crew 0.45.0 → 0.45.3

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 +133 -60
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -4804,7 +4804,6 @@ function referenceKeys(t) {
4804
4804
  }
4805
4805
  var RESOLVED_STATUSES = new Set(DEFAULT_CONTRACT.statuses.resolved);
4806
4806
  var encodeFilters = (f) => JSON.stringify(f);
4807
- var encodeSort = (s) => JSON.stringify(s);
4808
4807
  var Tracker = class {
4809
4808
  // NB: explicit fields, not TypeScript parameter properties — node's
4810
4809
  // --experimental-strip-types cannot transform those, and the test runner
@@ -4864,37 +4863,41 @@ var Tracker = class {
4864
4863
  });
4865
4864
  }
4866
4865
  /**
4867
- * Tickets at a terminal, no-longer-open status — the worktree sweep's
4868
- * input (ISSUE-346).
4866
+ * The tickets behind a set of ticket numbers — the cleanup sweeps' input
4867
+ * (CREW-1593).
4869
4868
  *
4870
- * The exact complement of `openTickets()`: `closed_deployed`,
4871
- * `closed_wont_fix`, `closed_duplicate`. `verified` is excluded even
4872
- * though the contract counts it as resolved for a *blocker*'s purposes —
4873
- * it is still pre-release, and its worktree is exactly what the release
4874
- * phase is about to merge.
4869
+ * The sweeps used to fetch "every closed ticket, newest `updated_at` first,
4870
+ * capped at 500" and hope the tickets they cared about were in the page. They
4871
+ * were not once the board held more than 500 closed tickets, and a bulk
4872
+ * write gave 1457 of them one identical `updated_at`, so which ones made the
4873
+ * page was arbitrary and a deployed ticket's worktree/branch was silently
4874
+ * never swept. The caller now names the numbers it actually has a worktree
4875
+ * or remote branch for, so the request is bounded by those and not by how
4876
+ * many tickets are closed.
4875
4877
  *
4876
- * Sorted most-recently-updated first: a board can hold far more than
4877
- * `limit` closed tickets (this one already has), and an unordered fetch
4878
- * has no guarantee which ones a truncated page contains. Ordering by
4879
- * `updatedAt` descending means a truncated page still holds the tickets
4880
- * most likely to need their worktree swept — the ones that closed most
4881
- * recently — rather than an arbitrary, possibly stale slice (ISSUE-403).
4878
+ * Matched on `issue_id`, in the raw, unpadded and 3-digit-padded spellings a
4879
+ * number has been stored under. `onTruncated` is told when a page came back
4880
+ * full, since anything past it would be missing without a trace.
4882
4881
  */
4883
- async terminalTickets() {
4882
+ async ticketsByNumbers(nums, onTruncated) {
4884
4883
  const c = this.contract;
4885
- const statuses = closedStatuses(c);
4886
- if (!statuses.length) return [];
4887
- const filters = [
4888
- { columnName: c.columns.status, operator: "IN", value: statuses }
4889
- ];
4890
- const areaId = this.route.resolved?.areaId;
4891
- if (areaId) filters.push({ columnName: c.columns.slice, operator: "EQ", value: areaId });
4892
- const params = {
4893
- filters: encodeFilters(filters),
4894
- limit: 500,
4895
- sort: encodeSort([{ columnName: c.columns.updatedAt, direction: "desc" }])
4896
- };
4897
- return this.client.records.list(this.models.issues, params);
4884
+ const keys = /* @__PURE__ */ new Set();
4885
+ for (const raw of nums) {
4886
+ const unpadded = String(Number.parseInt(raw, 10));
4887
+ for (const n of [raw, unpadded, unpadded.padStart(3, "0")]) keys.add(`ISSUE-${n}`);
4888
+ }
4889
+ const all = [...keys];
4890
+ const out = [];
4891
+ const limit = 500;
4892
+ for (let i = 0; i < all.length; i += 100) {
4893
+ const page = await this.client.records.list(this.models.issues, {
4894
+ filters: encodeFilters([{ columnName: c.columns.key, operator: "IN", value: all.slice(i, i + 100) }]),
4895
+ limit
4896
+ });
4897
+ if (page.length >= limit) onTruncated?.(limit);
4898
+ out.push(...page);
4899
+ }
4900
+ return out;
4898
4901
  }
4899
4902
  /**
4900
4903
  * Tickets by id, for blockers that have already closed and so are absent
@@ -11098,6 +11101,14 @@ function planWorktreeSweep(target, tickets, contract) {
11098
11101
  }
11099
11102
  return actions;
11100
11103
  }
11104
+ function worktreeTicketNumbers(target) {
11105
+ const nums = /* @__PURE__ */ new Set();
11106
+ for (const w of worktrees(target.dir)) {
11107
+ const m = /(?:^|-)(\d+)$/.exec(basename8(w.path));
11108
+ if (m) nums.add(m[1]);
11109
+ }
11110
+ return [...nums];
11111
+ }
11101
11112
  async function applyWorktreeSweep(cwd, actions, dryRun, log, remote = "origin") {
11102
11113
  let removed = 0;
11103
11114
  let keptBranches = 0;
@@ -11156,8 +11167,28 @@ async function applyWorktreeSweep(cwd, actions, dryRun, log, remote = "origin")
11156
11167
  }
11157
11168
  return { removed, keptBranches };
11158
11169
  }
11170
+ function remoteBranchTicketNumbers(cwd, remote, base) {
11171
+ if (!remoteConfigured(cwd, remote)) return [];
11172
+ const prefix = `refs/remotes/${remote}/`;
11173
+ const refs = (gitOk(cwd, ["for-each-ref", "--format=%(refname)", prefix]) ?? "").split("\n").filter(Boolean);
11174
+ const nums = /* @__PURE__ */ new Set();
11175
+ for (const ref of refs) {
11176
+ const name = ref.slice(prefix.length);
11177
+ if (name === "HEAD" || name === base) continue;
11178
+ const m = /^.+?-(\d+)(?:-.*)?$/.exec(name);
11179
+ if (m) nums.add(m[1]);
11180
+ }
11181
+ return [...nums];
11182
+ }
11159
11183
  function planRemoteBranchCleanup(cwd, remote, base, tickets, contract) {
11160
11184
  if (!remoteConfigured(cwd, remote)) return [];
11185
+ const stems = deployedBranchStems(tickets, contract);
11186
+ if (stems.size === 0) return [];
11187
+ const prefix = `refs/remotes/${remote}/`;
11188
+ const refs = (gitOk(cwd, ["for-each-ref", "--format=%(refname)", prefix]) ?? "").split("\n").filter(Boolean);
11189
+ return refs.map((ref) => ref.slice(prefix.length)).filter((name) => isTicketBranch(name, base, stems));
11190
+ }
11191
+ function deployedBranchStems(tickets, contract) {
11161
11192
  const stems = /* @__PURE__ */ new Set();
11162
11193
  for (const t of tickets) {
11163
11194
  if (t.status !== contract.statuses.deployed) continue;
@@ -11169,17 +11200,48 @@ function planRemoteBranchCleanup(cwd, remote, base, tickets, contract) {
11169
11200
  stems.add(`${p}-${unpadded}`);
11170
11201
  }
11171
11202
  }
11203
+ return stems;
11204
+ }
11205
+ function isTicketBranch(name, base, stems) {
11206
+ if (name === "HEAD" || name === base) return false;
11207
+ const m = /^(.+?-\d+)(?:-.*)?$/.exec(name);
11208
+ return !!m && stems.has(m[1]);
11209
+ }
11210
+ function sweepCandidatesExist(cwd, base) {
11211
+ const linked = worktrees(cwd).slice(1);
11212
+ if (linked.some((w) => /(?:^|-)\d+$/.test(basename8(w.path)))) return true;
11213
+ return localBranches(cwd).some((b) => b !== base && /-\d+(?:-.*)?$/.test(b));
11214
+ }
11215
+ function localBranchTicketNumbers(cwd, base) {
11216
+ const nums = /* @__PURE__ */ new Set();
11217
+ for (const b of localBranches(cwd)) {
11218
+ const m = b === base ? null : /^.+?-(\d+)(?:-.*)?$/.exec(b);
11219
+ if (m) nums.add(m[1]);
11220
+ }
11221
+ return [...nums];
11222
+ }
11223
+ function localBranches(cwd) {
11224
+ return (gitOk(cwd, ["branch", "--list", "--format=%(refname:short)"]) ?? "").split("\n").filter(Boolean);
11225
+ }
11226
+ function planLocalBranchCleanup(cwd, base, tickets, contract) {
11227
+ const stems = deployedBranchStems(tickets, contract);
11172
11228
  if (stems.size === 0) return [];
11173
- const prefix = `refs/remotes/${remote}/`;
11174
- const refs = (gitOk(cwd, ["for-each-ref", "--format=%(refname)", prefix]) ?? "").split("\n").filter(Boolean);
11175
- const out = [];
11176
- for (const ref of refs) {
11177
- const name = ref.slice(prefix.length);
11178
- if (name === "HEAD" || name === base) continue;
11179
- const m = /^(.+?-\d+)(?:-.*)?$/.exec(name);
11180
- if (m && stems.has(m[1])) out.push(name);
11229
+ const checkedOut = new Set(worktrees(cwd).map((w) => w.branch).filter((b) => !!b));
11230
+ return localBranches(cwd).filter((b) => !checkedOut.has(b) && isTicketBranch(b, base, stems));
11231
+ }
11232
+ function applyLocalBranchCleanup(cwd, branches2, dryRun, log) {
11233
+ let deleted = 0;
11234
+ for (const b of branches2) {
11235
+ if (dryRun) {
11236
+ log.emit(`would delete local branch ${b} \u2014 its ticket is deployed`, { step: "worktree" });
11237
+ continue;
11238
+ }
11239
+ if (gitOk(cwd, ["branch", "-D", b]) !== null) {
11240
+ deleted++;
11241
+ log.emit(`deleted local branch ${b} \u2014 its ticket is deployed`, { step: "worktree" });
11242
+ } else log.warn(`could not delete local branch ${b} \u2014 leaving it for the next sweep`, { step: "worktree" });
11181
11243
  }
11182
- return out;
11244
+ return deleted;
11183
11245
  }
11184
11246
  function applyRemoteBranchCleanup(cwd, remote, branches2, dryRun, log) {
11185
11247
  let deleted = 0;
@@ -12984,6 +13046,30 @@ ${changed2.map((c) => ` - ${c}
12984
13046
  }
12985
13047
  return `${who2}; ${lock2}`;
12986
13048
  }
13049
+ async function sweepClosedTickets(c, target, branch, tracker, dryRun2, remit, idle = false) {
13050
+ try {
13051
+ if (idle && !sweepCandidatesExist(target.dir, branch.base)) return;
13052
+ const truncated = (limit) => remit.warn(`ticket lookup for the sweep hit its ${limit}-row limit; some tickets may be missed`, { step: "worktree" });
13053
+ const nums = /* @__PURE__ */ new Set([
13054
+ ...worktreeTicketNumbers(target),
13055
+ ...localBranchTicketNumbers(target.dir, branch.base),
13056
+ ...remoteBranchTicketNumbers(target.dir, branch.remote, branch.base)
13057
+ ]);
13058
+ const scoped = ticketsByRepo(c, await tracker.ticketsByNumbers([...nums], truncated)).byRepo.get(target.name) ?? [];
13059
+ const actions = planWorktreeSweep(target, scoped, tracker.contract);
13060
+ if (actions.length) {
13061
+ remit.enter("worktree");
13062
+ const r = await applyWorktreeSweep(target.dir, actions, dryRun2, remit, branch.remote);
13063
+ remit.emit(`swept ${r.removed} worktree(s), kept ${r.keptBranches} branch(es)`, { step: "worktree" });
13064
+ }
13065
+ const orphaned = planLocalBranchCleanup(target.dir, branch.base, scoped, tracker.contract);
13066
+ if (orphaned.length) applyLocalBranchCleanup(target.dir, orphaned, dryRun2, remit);
13067
+ const stale = planRemoteBranchCleanup(target.dir, branch.remote, branch.base, scoped, tracker.contract);
13068
+ if (stale.length) applyRemoteBranchCleanup(target.dir, branch.remote, stale, dryRun2, remit);
13069
+ } catch (e) {
13070
+ remit.warn(`worktree sweep failed: ${e.message}`, { step: "worktree" });
13071
+ }
13072
+ }
12987
13073
  const precheckTickets = /* @__PURE__ */ new Map();
12988
13074
  async function releasePhase(c, target, opts = {}) {
12989
13075
  const scope = releaseLockScope(c.route, target.name);
@@ -13053,6 +13139,7 @@ ${changed2.map((c) => ` - ${c}
13053
13139
  unstamped
13054
13140
  })) {
13055
13141
  remit.emit(`${target.name}: nothing verified \u2014 not locking`, { step: "release" });
13142
+ await sweepClosedTickets(c, target, repo.branch, tracker, dryRun, remit, true);
13056
13143
  return { scope, tests: "skipped", outcome: "skipped", detail: "nothing verified \u2014 not locking" };
13057
13144
  }
13058
13145
  }
@@ -13257,21 +13344,7 @@ ${changed2.map((c) => ` - ${c}
13257
13344
  }
13258
13345
  }
13259
13346
  }
13260
- try {
13261
- const terminal = await tracker.terminalTickets();
13262
- const verifiedNow = (await tracker.openTickets()).filter((t) => t.status === tracker.contract.statuses.verified);
13263
- const scoped = ticketsByRepo(c, terminal).byRepo.get(target.name) ?? [];
13264
- const actions = planWorktreeSweep(target, [...scoped, ...ticketsByRepo(c, verifiedNow).byRepo.get(target.name) ?? []], tracker.contract);
13265
- if (actions.length) {
13266
- remit.enter("worktree");
13267
- const r = await applyWorktreeSweep(target.dir, actions, dryRun, remit, repo.branch.remote);
13268
- remit.emit(`swept ${r.removed} worktree(s), kept ${r.keptBranches} branch(es)`, { step: "worktree" });
13269
- }
13270
- const stale = planRemoteBranchCleanup(target.dir, repo.branch.remote, repo.branch.base, scoped, tracker.contract);
13271
- if (stale.length) applyRemoteBranchCleanup(target.dir, repo.branch.remote, stale, dryRun, remit);
13272
- } catch (e) {
13273
- remit.warn(`worktree sweep failed: ${e.message}`, { step: "worktree" });
13274
- }
13347
+ await sweepClosedTickets(c, target, repo.branch, tracker, dryRun, remit);
13275
13348
  return summary;
13276
13349
  } catch (e) {
13277
13350
  remit.warn(`release phase failed: ${e.message}`, { step: "release" });
@@ -14672,15 +14745,15 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
14672
14745
  }
14673
14746
  if (!dryRun && orphans.length) await killGently(orphans.map((o) => o.pid));
14674
14747
  const tracker = new Tracker(route, cfg.ship);
14675
- const terminal = await tracker.terminalTickets();
14676
- const { byRepo } = ticketsByRepo(route, terminal);
14677
- const verifiedByRepo = ticketsByRepo(
14678
- route,
14679
- (await tracker.openTickets()).filter((t) => t.status === tracker.contract.statuses.verified)
14680
- ).byRepo;
14748
+ const truncated = (limit) => emit.warn(`ticket lookup for the sweep hit its ${limit}-row limit; some tickets may be missed`, { step: "worktree" });
14681
14749
  for (const r of await resolvedRepos(route)) {
14682
- const actions = planWorktreeSweep(r, [...byRepo.get(r.name) ?? [], ...verifiedByRepo.get(r.name) ?? []], tracker.contract);
14683
- const stale = planRemoteBranchCleanup(r.dir, r.config.branch.remote, r.config.branch.base, byRepo.get(r.name) ?? [], tracker.contract);
14750
+ const wtTickets = ticketsByRepo(route, await tracker.ticketsByNumbers(worktreeTicketNumbers(r), truncated)).byRepo.get(r.name) ?? [];
14751
+ const brTickets = ticketsByRepo(
14752
+ route,
14753
+ await tracker.ticketsByNumbers(remoteBranchTicketNumbers(r.dir, r.config.branch.remote, r.config.branch.base), truncated)
14754
+ ).byRepo.get(r.name) ?? [];
14755
+ const actions = planWorktreeSweep(r, wtTickets, tracker.contract);
14756
+ const stale = planRemoteBranchCleanup(r.dir, r.config.branch.remote, r.config.branch.base, brTickets, tracker.contract);
14684
14757
  if (stale.length) {
14685
14758
  anything = true;
14686
14759
  applyRemoteBranchCleanup(r.dir, r.config.branch.remote, stale, dryRun, emit);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.45.0",
3
+ "version": "0.45.3",
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",