@tablation/crew 0.2.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.
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
|
}
|
|
@@ -2746,7 +2769,7 @@ function qaDigest(i) {
|
|
|
2746
2769
|
"## Current queue \u2014 built for you by the poll\n",
|
|
2747
2770
|
'\nAlready filtered to your lane: every ticket at `qa` ("Verification" \u2014\nyours, unfinished) or `fixed` (nobody has checked it yet), from both\nbuilding lanes. Ticket bodies are deliberately omitted: fetch the full\nrecord and the comments of the one ticket you actually pick up. **Do not\nre-fetch the whole tracker.** This digest comes from the same API call the\npoll just made, moments ago.\n',
|
|
2748
2771
|
'\n"new since last poll" counts comments from someone other than you since\nthe poll watermark \u2014 the same signal that woke this run.\n',
|
|
2749
|
-
"\n**`repo` is the checkout the ticket's work happens in** \u2014 an area spans\nseveral repositories, so the worktree you verify in sits beside THAT\ndirectory, not beside whichever one this session started in. A ticket\nmarked **NO CHECKOUT** is not yours: this ship has no clone of its\nrepository, and another ship may serve it.\n\n**`branch` is that repository's own branch for the ticket**, found there\nrather than derived. **MISSING** means the branch is gone
|
|
2772
|
+
"\n**`repo` is the checkout the ticket's work happens in** \u2014 an area spans\nseveral repositories, so the worktree you verify in sits beside THAT\ndirectory, not beside whichever one this session started in. A ticket\nmarked **NO CHECKOUT** is not yours: this ship has no clone of its\nrepository, and another ship may serve it.\n\n**`branch` is that repository's own branch for the ticket**, found there\nrather than derived. **`<name> (on origin only)`** means another ship built it\nand pushed it: it is testable \u2014 cut a worktree from the remote branch\n(`git worktree add <worktree> <name>`). **MISSING** means the branch is gone\nlocally AND on the remote, so there is nothing left to verify \u2014 say so on the ticket. A `\u2014` means the branch\ncould not be looked for at all, because the repo has no checkout here.\n\n**`worktree` is where that branch should be checked out**, `<repo>/../<worktree>`\n\u2014 rendered the same way the dev/design digest computes it, so you land in\nthe same directory the building lane used.\n",
|
|
2750
2773
|
"\n### Still in verification \u2014 yours, unfinished (take these first)\n",
|
|
2751
2774
|
table(i.tickets.filter((t) => t.status === "qa"), i, header, row),
|
|
2752
2775
|
"\n### Awaiting verification, in pick order\n",
|
|
@@ -2813,6 +2836,13 @@ function branches(cwd, pattern) {
|
|
|
2813
2836
|
return out ? out.split("\n").filter(Boolean) : [];
|
|
2814
2837
|
}
|
|
2815
2838
|
function branchForIssue(cwd, issueKey, templates = { name: "issue-{number}" }, render2 = (t) => t.replace(/\{number\}/g, issueKey.replace(/^\D+/, ""))) {
|
|
2839
|
+
for (const c of branchCandidates(issueKey, templates, render2)) {
|
|
2840
|
+
const found = branches(cwd, c);
|
|
2841
|
+
if (found.length) return found[0];
|
|
2842
|
+
}
|
|
2843
|
+
return null;
|
|
2844
|
+
}
|
|
2845
|
+
function branchCandidates(issueKey, templates, render2) {
|
|
2816
2846
|
const num = issueKey.replace(/^\D+/, "");
|
|
2817
2847
|
const unpadded = String(Number.parseInt(num, 10));
|
|
2818
2848
|
const candidates = [];
|
|
@@ -2827,12 +2857,31 @@ function branchForIssue(cwd, issueKey, templates = { name: "issue-{number}" }, r
|
|
|
2827
2857
|
}
|
|
2828
2858
|
candidates.push(`issue-${num}`);
|
|
2829
2859
|
if (num !== unpadded) candidates.push(`issue-${unpadded}`);
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2860
|
+
return [...new Set(candidates)];
|
|
2861
|
+
}
|
|
2862
|
+
function remoteBranchForIssue(cwd, remote, issueKey, templates = { name: "issue-{number}" }, render2 = (t) => t.replace(/\{number\}/g, issueKey.replace(/^\D+/, ""))) {
|
|
2863
|
+
const prefix = `refs/remotes/${remote}/`;
|
|
2864
|
+
for (const c of branchCandidates(issueKey, templates, render2)) {
|
|
2865
|
+
const out = gitOk(cwd, ["for-each-ref", "--format=%(refname)", `${prefix}${c}`]);
|
|
2866
|
+
const hit = out?.split("\n").filter((r) => r.startsWith(prefix) && r !== `${prefix}HEAD`)[0];
|
|
2867
|
+
if (hit) return hit.slice(prefix.length);
|
|
2833
2868
|
}
|
|
2834
2869
|
return null;
|
|
2835
2870
|
}
|
|
2871
|
+
function materializeRemoteBranch(cwd, remote, branch) {
|
|
2872
|
+
if (!remoteBranchExists(cwd, remote, branch)) {
|
|
2873
|
+
return gitOk(cwd, ["rev-parse", "-q", "--verify", `refs/heads/${branch}`]) !== null;
|
|
2874
|
+
}
|
|
2875
|
+
if (gitOk(cwd, ["rev-parse", "-q", "--verify", `refs/heads/${branch}`]) === null) {
|
|
2876
|
+
return gitOk(cwd, ["branch", "--track", branch, `${remote}/${branch}`]) !== null;
|
|
2877
|
+
}
|
|
2878
|
+
gitOk(cwd, ["fetch", "--quiet", remote, `${branch}:${branch}`]);
|
|
2879
|
+
return true;
|
|
2880
|
+
}
|
|
2881
|
+
function deleteRemoteBranch(cwd, remote, branch) {
|
|
2882
|
+
if (!remoteBranchExists(cwd, remote, branch)) return true;
|
|
2883
|
+
return gitOk(cwd, ["push", remote, "--delete", branch]) !== null;
|
|
2884
|
+
}
|
|
2836
2885
|
function worktreeForNumber(cwd, num) {
|
|
2837
2886
|
const re = new RegExp(`(?:^|-)${num}$`);
|
|
2838
2887
|
return worktrees(cwd).find((w) => re.test(basename3(w.path))) ?? null;
|
|
@@ -2843,6 +2892,10 @@ var countCommits = (cwd, range) => Number.parseInt(gitOk(cwd, ["rev-list", "--co
|
|
|
2843
2892
|
function latestReleaseTag(cwd, pattern = "v*", ref = "HEAD") {
|
|
2844
2893
|
return gitOk(cwd, ["describe", "--tags", "--abbrev=0", "--match", pattern, ref]);
|
|
2845
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
|
+
}
|
|
2846
2899
|
var tagCommit = (cwd, tag) => resolve2(cwd, tag);
|
|
2847
2900
|
function createReleaseTag(cwd, tag, sha, message) {
|
|
2848
2901
|
git(cwd, ["tag", "-a", tag, sha, "-m", message]);
|
|
@@ -3138,11 +3191,32 @@ function worktreeForTicket(dir, cfg, t, role) {
|
|
|
3138
3191
|
const number = t.issue_id.replace(/^\D+/, "");
|
|
3139
3192
|
return effectiveWorktreeDirName(cfg, dir, branchName, number);
|
|
3140
3193
|
}
|
|
3141
|
-
function
|
|
3194
|
+
function branchTemplates(cfg, t) {
|
|
3142
3195
|
const { prefix } = ticketBranchContext(t);
|
|
3143
3196
|
const name = effectiveBranchTemplate(cfg, prefix);
|
|
3144
3197
|
const push = cfg.provenance["branch.push"] === "default" ? name : cfg.branch.push;
|
|
3145
|
-
return
|
|
3198
|
+
return { name, push };
|
|
3199
|
+
}
|
|
3200
|
+
function existingBranchForTicket(dir, cfg, t, role) {
|
|
3201
|
+
return branchForIssue(dir, t.issue_id, branchTemplates(cfg, t), branchRenderer(t, role));
|
|
3202
|
+
}
|
|
3203
|
+
function locateBranchForTicket(dir, cfg, t, role, fetchedDirs) {
|
|
3204
|
+
const local = existingBranchForTicket(dir, cfg, t, role);
|
|
3205
|
+
if (local) return { branch: local, where: "local" };
|
|
3206
|
+
const remote = cfg.branch.remote;
|
|
3207
|
+
if (!remoteConfigured(dir, remote)) return null;
|
|
3208
|
+
if (fetchedDirs && !fetchedDirs.has(dir)) {
|
|
3209
|
+
fetchedDirs.add(dir);
|
|
3210
|
+
fetchRemote(dir, remote);
|
|
3211
|
+
}
|
|
3212
|
+
const found = remoteBranchForIssue(dir, remote, t.issue_id, branchTemplates(cfg, t), branchRenderer(t, role));
|
|
3213
|
+
return found ? { branch: found, where: "remote" } : null;
|
|
3214
|
+
}
|
|
3215
|
+
function materializeBranchForTicket(dir, cfg, t, role) {
|
|
3216
|
+
const loc = locateBranchForTicket(dir, cfg, t, role);
|
|
3217
|
+
if (!loc) return null;
|
|
3218
|
+
materializeRemoteBranch(dir, cfg.branch.remote, loc.branch);
|
|
3219
|
+
return loc.branch;
|
|
3146
3220
|
}
|
|
3147
3221
|
|
|
3148
3222
|
// src/poll.ts
|
|
@@ -3312,6 +3386,7 @@ function writeDigest(o, d, role, stateDir) {
|
|
|
3312
3386
|
repoCache.set(dir, cfg);
|
|
3313
3387
|
return cfg;
|
|
3314
3388
|
};
|
|
3389
|
+
const fetchedDirs = /* @__PURE__ */ new Set();
|
|
3315
3390
|
const dirFor = (t) => dirForRepo(o.route, t.repo_id);
|
|
3316
3391
|
const render2 = (t) => branchRenderer(t, role);
|
|
3317
3392
|
const input = {
|
|
@@ -3336,6 +3411,10 @@ function writeDigest(o, d, role, stateDir) {
|
|
|
3336
3411
|
existingBranchFor: (t) => {
|
|
3337
3412
|
const dir = dirFor(t);
|
|
3338
3413
|
if (!dir) return null;
|
|
3414
|
+
if (role === "qa") {
|
|
3415
|
+
const loc = locateBranchForTicket(dir, repoFor(dir), t, role, fetchedDirs);
|
|
3416
|
+
return loc ? loc.where === "remote" ? `${loc.branch} (on ${repoFor(dir).branch.remote} only)` : loc.branch : null;
|
|
3417
|
+
}
|
|
3339
3418
|
return existingBranchForTicket(dir, repoFor(dir), t, role);
|
|
3340
3419
|
},
|
|
3341
3420
|
tickets: role === "triage" ? d.tickets.filter((t) => t.assignee_id === o.route.resolved?.seats.triage) : sliceFor(d.tickets, role),
|
|
@@ -3510,16 +3589,24 @@ async function applyConflictBounce(writer, b, contract, memberId, log, dryRun) {
|
|
|
3510
3589
|
|
|
3511
3590
|
// src/stranded-verified.ts
|
|
3512
3591
|
var STRANDED_MARKER = "<!-- crew:stranded-verified -->";
|
|
3592
|
+
var STRANDED_ESCALATE_AFTER_MS = 30 * 60 * 1e3;
|
|
3513
3593
|
var priorStrandedFlags = (ticket, comments) => comments.filter((c) => c.ticket_id === ticket.id && (c.body ?? "").includes(STRANDED_MARKER)).length;
|
|
3514
3594
|
function planStrandedVerified(candidate, base, remote, comments) {
|
|
3515
|
-
|
|
3595
|
+
const stamps = comments.filter((c) => c.ticket_id === candidate.ticket.id && (c.body ?? "").includes(STRANDED_MARKER)).map((c) => Date.parse(c.created_at)).filter((n) => !Number.isNaN(n));
|
|
3596
|
+
return {
|
|
3597
|
+
candidate,
|
|
3598
|
+
base,
|
|
3599
|
+
remote,
|
|
3600
|
+
priorFlags: priorStrandedFlags(candidate.ticket, comments),
|
|
3601
|
+
lastFlaggedAt: stamps.length ? Math.max(...stamps) : void 0
|
|
3602
|
+
};
|
|
3516
3603
|
}
|
|
3517
3604
|
function flagComment(s) {
|
|
3518
3605
|
const key = s.candidate.ticket.issue_id;
|
|
3519
3606
|
return `${STRANDED_MARKER}
|
|
3520
3607
|
**Verified, but nothing to merge \u2014 flagging before this repeats forever.**
|
|
3521
3608
|
|
|
3522
|
-
\`${key}\` is \`verified\` with no branch on this ship, and its key names no commit
|
|
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.
|
|
3523
3610
|
|
|
3524
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.`;
|
|
3525
3612
|
}
|
|
@@ -3528,12 +3615,16 @@ function escalateComment2(s) {
|
|
|
3528
3615
|
return `${STRANDED_MARKER}
|
|
3529
3616
|
**Still nothing to merge \u2014 stopping rather than repeating.**
|
|
3530
3617
|
|
|
3531
|
-
\`${key}\` was flagged as stranded at \`verified\` on an earlier cycle and still has no branch on this ship and no commit
|
|
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.
|
|
3532
3619
|
|
|
3533
3620
|
The crew will not close this on its own.`;
|
|
3534
3621
|
}
|
|
3535
|
-
async function applyStrandedVerified(writer, s, contract, operatorId, memberId, log, dryRun) {
|
|
3622
|
+
async function applyStrandedVerified(writer, s, contract, operatorId, memberId, log, dryRun, now = Date.now()) {
|
|
3536
3623
|
const t = { ticket: s.candidate.ticket.issue_id, step: "merge" };
|
|
3624
|
+
if (s.priorFlags > 0 && s.lastFlaggedAt !== void 0 && now - s.lastFlaggedAt < STRANDED_ESCALATE_AFTER_MS) {
|
|
3625
|
+
log.emit("flagged as stranded recently \u2014 not escalating yet", t);
|
|
3626
|
+
return { kind: "waiting" };
|
|
3627
|
+
}
|
|
3537
3628
|
if (s.priorFlags > 0) {
|
|
3538
3629
|
if (dryRun) {
|
|
3539
3630
|
log.emit(`would escalate to ${contract.statuses.needsHuman} \u2014 flagged before`, t);
|
|
@@ -3568,6 +3659,33 @@ async function applyStrandedVerified(writer, s, contract, operatorId, memberId,
|
|
|
3568
3659
|
}
|
|
3569
3660
|
}
|
|
3570
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
|
+
|
|
3571
3689
|
// src/agent.ts
|
|
3572
3690
|
import { spawn } from "child_process";
|
|
3573
3691
|
import { readFileSync as readFileSync5, existsSync as existsSync6, statSync as statSync2, mkdirSync as mkdirSync4, writeFileSync as writeFileSync3, createWriteStream } from "fs";
|
|
@@ -4824,7 +4942,32 @@ function planMerge(cwd, tickets, contract, lastReleased, branchFor = (t) => bran
|
|
|
4824
4942
|
return verified.map((ticket) => {
|
|
4825
4943
|
const branch = branchFor(ticket);
|
|
4826
4944
|
if (!branch) {
|
|
4827
|
-
const
|
|
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
|
+
}
|
|
4828
4971
|
return {
|
|
4829
4972
|
ticket,
|
|
4830
4973
|
branch: null,
|
|
@@ -4832,7 +4975,7 @@ function planMerge(cwd, tickets, contract, lastReleased, branchFor = (t) => bran
|
|
|
4832
4975
|
usedFallback: false,
|
|
4833
4976
|
bump: "patch",
|
|
4834
4977
|
majorRequested: false,
|
|
4835
|
-
skipReason:
|
|
4978
|
+
skipReason: "never-built"
|
|
4836
4979
|
};
|
|
4837
4980
|
}
|
|
4838
4981
|
const squash = findSquashOfBranch(
|
|
@@ -5313,7 +5456,7 @@ function refreshBase(o) {
|
|
|
5313
5456
|
return { ok: true };
|
|
5314
5457
|
case "fetch-failed":
|
|
5315
5458
|
o.emit.warn(`${r.detail} \u2014 releasing from what this ship already has`);
|
|
5316
|
-
return { ok: true };
|
|
5459
|
+
return { ok: true, stale: true };
|
|
5317
5460
|
case "would-fast-forward":
|
|
5318
5461
|
case "fast-forwarded":
|
|
5319
5462
|
o.emit.emit(r.detail);
|
|
@@ -5356,7 +5499,10 @@ async function runRelease(o) {
|
|
|
5356
5499
|
const decision = decideRelease(o.cwd, o.tickets, o.contract, {
|
|
5357
5500
|
tagPattern,
|
|
5358
5501
|
base: o.repo.branch.base,
|
|
5359
|
-
|
|
5502
|
+
// CREW-1364: a branch another ship built and pushed at `fixed` exists
|
|
5503
|
+
// here only on the remote. Materialize it so the squash below has a ref.
|
|
5504
|
+
// A dry run only looks.
|
|
5505
|
+
branchFor: (t) => o.dryRun ? locateBranchForTicket(o.cwd, o.repo, t)?.branch ?? null : materializeBranchForTicket(o.cwd, o.repo, t)
|
|
5360
5506
|
});
|
|
5361
5507
|
if (!fresh.ok) {
|
|
5362
5508
|
o.emit.error(scoped(`refusing to release \u2014 ${fresh.why}; a person has to reconcile the two`));
|
|
@@ -5398,14 +5544,21 @@ async function runRelease(o) {
|
|
|
5398
5544
|
let applied = 0;
|
|
5399
5545
|
for (const c of decision.merges) {
|
|
5400
5546
|
if (!c.branch || c.mergedSha) {
|
|
5401
|
-
if (c.skipReason === "already-
|
|
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") {
|
|
5402
5553
|
o.emit.emit(
|
|
5403
5554
|
`verified and already merged${c.mergedSha ? ` (on the base as ${c.mergedSha.slice(0, 7)})` : ""}, waiting on a successful release`,
|
|
5404
5555
|
{ ticket: c.ticket.issue_id }
|
|
5405
5556
|
);
|
|
5406
5557
|
} else {
|
|
5407
5558
|
o.emit.emit("verified but has no branch and nothing on the base names it \u2014 nothing to merge", { ticket: c.ticket.issue_id });
|
|
5408
|
-
|
|
5559
|
+
if (fresh.ok && fresh.stale) {
|
|
5560
|
+
o.emit.warn("not flagging as stranded \u2014 the remote could not be fetched, so the branch may exist there", { ticket: c.ticket.issue_id });
|
|
5561
|
+
} else unbuildable.push(c);
|
|
5409
5562
|
}
|
|
5410
5563
|
continue;
|
|
5411
5564
|
}
|
|
@@ -5501,8 +5654,12 @@ async function runRelease(o) {
|
|
|
5501
5654
|
}
|
|
5502
5655
|
if (r && r.code !== 0) {
|
|
5503
5656
|
o.emit.error(`test gate FAILED \u2014 not deploying; the target stays on the previous release`);
|
|
5504
|
-
|
|
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 };
|
|
5505
5661
|
}
|
|
5662
|
+
o.state?.clearTestGateFailed?.();
|
|
5506
5663
|
}
|
|
5507
5664
|
}
|
|
5508
5665
|
const size = merged.some((m) => m.bump === "minor") ? "minor" : "patch";
|
|
@@ -7199,6 +7356,7 @@ function schedulerInstalled(plan, exists, crontab) {
|
|
|
7199
7356
|
}
|
|
7200
7357
|
|
|
7201
7358
|
// src/worktree-sweep.ts
|
|
7359
|
+
import { existsSync as existsSync14 } from "fs";
|
|
7202
7360
|
import { basename as basename6 } from "path";
|
|
7203
7361
|
function planWorktreeSweep(target, tickets, contract) {
|
|
7204
7362
|
const terminal = new Set(contract.statuses.resolved.filter((s) => s !== contract.statuses.verified));
|
|
@@ -7218,10 +7376,24 @@ function planWorktreeSweep(target, tickets, contract) {
|
|
|
7218
7376
|
}
|
|
7219
7377
|
return actions;
|
|
7220
7378
|
}
|
|
7221
|
-
async function applyWorktreeSweep(cwd, actions, dryRun, log) {
|
|
7379
|
+
async function applyWorktreeSweep(cwd, actions, dryRun, log, remote = "origin") {
|
|
7222
7380
|
let removed = 0;
|
|
7223
7381
|
let keptBranches = 0;
|
|
7224
7382
|
for (const a of actions) {
|
|
7383
|
+
if (!dryRun && existsSync14(a.path)) {
|
|
7384
|
+
let dirty = false;
|
|
7385
|
+
try {
|
|
7386
|
+
dirty = status(a.path).length > 0;
|
|
7387
|
+
} catch {
|
|
7388
|
+
}
|
|
7389
|
+
if (dirty) {
|
|
7390
|
+
log.warn(
|
|
7391
|
+
`not removing worktree ${a.path} for ${a.ticket.issue_id} (${a.ticket.status}): it has uncommitted changes`,
|
|
7392
|
+
{ ticket: a.ticket.issue_id, step: "worktree" }
|
|
7393
|
+
);
|
|
7394
|
+
continue;
|
|
7395
|
+
}
|
|
7396
|
+
}
|
|
7225
7397
|
const n = Number(a.ticket.issue_id.replace(/^\D+/, ""));
|
|
7226
7398
|
const pids = pidsInWorktree(a.path, n);
|
|
7227
7399
|
if (pids.length) {
|
|
@@ -7241,7 +7413,12 @@ async function applyWorktreeSweep(cwd, actions, dryRun, log) {
|
|
|
7241
7413
|
continue;
|
|
7242
7414
|
}
|
|
7243
7415
|
gitOk(cwd, ["worktree", "remove", "--force", a.path]);
|
|
7244
|
-
if (!a.keepBranch && a.branch)
|
|
7416
|
+
if (!a.keepBranch && a.branch) {
|
|
7417
|
+
gitOk(cwd, ["branch", "-D", a.branch]);
|
|
7418
|
+
if (remoteConfigured(cwd, remote) && !deleteRemoteBranch(cwd, remote, a.branch)) {
|
|
7419
|
+
log.warn(`could not delete ${remote}/${a.branch} \u2014 leaving it for the next sweep`, { ticket: a.ticket.issue_id, step: "worktree" });
|
|
7420
|
+
}
|
|
7421
|
+
}
|
|
7245
7422
|
log.emit(
|
|
7246
7423
|
`removed worktree for ${a.ticket.issue_id} (${a.ticket.status}); ${branchNote}`,
|
|
7247
7424
|
{ ticket: a.ticket.issue_id, step: "worktree" }
|
|
@@ -7250,14 +7427,53 @@ async function applyWorktreeSweep(cwd, actions, dryRun, log) {
|
|
|
7250
7427
|
}
|
|
7251
7428
|
return { removed, keptBranches };
|
|
7252
7429
|
}
|
|
7430
|
+
function planRemoteBranchCleanup(cwd, remote, base, tickets, contract) {
|
|
7431
|
+
if (!remoteConfigured(cwd, remote)) return [];
|
|
7432
|
+
const stems = /* @__PURE__ */ new Set();
|
|
7433
|
+
for (const t of tickets) {
|
|
7434
|
+
if (t.status !== contract.statuses.deployed) continue;
|
|
7435
|
+
const num = t.issue_id.replace(/^\D+/, "");
|
|
7436
|
+
const unpadded = String(Number.parseInt(num, 10));
|
|
7437
|
+
const prefixes = ["issue", t.project_issue_prefix?.toLowerCase()].filter((p) => !!p);
|
|
7438
|
+
for (const p of prefixes) {
|
|
7439
|
+
stems.add(`${p}-${num}`);
|
|
7440
|
+
stems.add(`${p}-${unpadded}`);
|
|
7441
|
+
}
|
|
7442
|
+
}
|
|
7443
|
+
if (stems.size === 0) return [];
|
|
7444
|
+
const prefix = `refs/remotes/${remote}/`;
|
|
7445
|
+
const refs = (gitOk(cwd, ["for-each-ref", "--format=%(refname)", prefix]) ?? "").split("\n").filter(Boolean);
|
|
7446
|
+
const out = [];
|
|
7447
|
+
for (const ref of refs) {
|
|
7448
|
+
const name = ref.slice(prefix.length);
|
|
7449
|
+
if (name === "HEAD" || name === base) continue;
|
|
7450
|
+
const m = /^(.+?-\d+)(?:-.*)?$/.exec(name);
|
|
7451
|
+
if (m && stems.has(m[1])) out.push(name);
|
|
7452
|
+
}
|
|
7453
|
+
return out;
|
|
7454
|
+
}
|
|
7455
|
+
function applyRemoteBranchCleanup(cwd, remote, branches2, dryRun, log) {
|
|
7456
|
+
let deleted = 0;
|
|
7457
|
+
for (const b of branches2) {
|
|
7458
|
+
if (dryRun) {
|
|
7459
|
+
log.emit(`would delete ${remote}/${b} \u2014 its ticket is deployed`, { step: "worktree" });
|
|
7460
|
+
continue;
|
|
7461
|
+
}
|
|
7462
|
+
if (deleteRemoteBranch(cwd, remote, b)) {
|
|
7463
|
+
deleted++;
|
|
7464
|
+
log.emit(`deleted ${remote}/${b} \u2014 its ticket is deployed`, { step: "worktree" });
|
|
7465
|
+
} else log.warn(`could not delete ${remote}/${b} \u2014 leaving it for the next sweep`, { step: "worktree" });
|
|
7466
|
+
}
|
|
7467
|
+
return deleted;
|
|
7468
|
+
}
|
|
7253
7469
|
|
|
7254
7470
|
// src/stream-sweep.ts
|
|
7255
|
-
import { existsSync as
|
|
7471
|
+
import { existsSync as existsSync15, readdirSync as readdirSync2, rmSync as rmSync2, statSync as statSync4 } from "fs";
|
|
7256
7472
|
import { join as join13 } from "path";
|
|
7257
7473
|
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
7258
7474
|
function planStreamSweep(stateDir, retentionDays, now = Date.now()) {
|
|
7259
7475
|
const dir = join13(stateDir, "streams");
|
|
7260
|
-
if (!
|
|
7476
|
+
if (!existsSync15(dir)) return [];
|
|
7261
7477
|
const cutoff = now - retentionDays * DAY_MS;
|
|
7262
7478
|
return readdirSync2(dir).map((name) => join13(dir, name)).filter((path) => {
|
|
7263
7479
|
try {
|
|
@@ -7286,13 +7502,13 @@ function applyStreamSweep(paths, dryRun, log) {
|
|
|
7286
7502
|
}
|
|
7287
7503
|
|
|
7288
7504
|
// src/cli.ts
|
|
7289
|
-
import { readFileSync as readFileSync12, existsSync as
|
|
7505
|
+
import { readFileSync as readFileSync12, existsSync as existsSync19, mkdirSync as mkdirSync10, writeFileSync as writeFileSync9, unlinkSync as unlinkSync5, copyFileSync, renameSync } from "fs";
|
|
7290
7506
|
import { createInterface } from "readline/promises";
|
|
7291
7507
|
import { dirname as dirOf, resolve as resolvePath } from "path";
|
|
7292
7508
|
|
|
7293
7509
|
// src/ssh-keys.ts
|
|
7294
7510
|
import { execFileSync as execFileSync3 } from "child_process";
|
|
7295
|
-
import { existsSync as
|
|
7511
|
+
import { existsSync as existsSync16, mkdirSync as mkdirSync7, readFileSync as readFileSync9, chmodSync } from "fs";
|
|
7296
7512
|
import { dirname as dirname8, join as join14 } from "path";
|
|
7297
7513
|
var SshKeyError = class extends Error {
|
|
7298
7514
|
};
|
|
@@ -7302,7 +7518,7 @@ function shipSshKeyPathFor(stateDir) {
|
|
|
7302
7518
|
function ensureShipSshKeypair(stateDir) {
|
|
7303
7519
|
const privateKeyPath = shipSshKeyPathFor(stateDir);
|
|
7304
7520
|
const publicKeyPath = `${privateKeyPath}.pub`;
|
|
7305
|
-
if (!
|
|
7521
|
+
if (!existsSync16(privateKeyPath)) {
|
|
7306
7522
|
mkdirSync7(dirname8(privateKeyPath), { recursive: true });
|
|
7307
7523
|
try {
|
|
7308
7524
|
execFileSync3("ssh-keygen", [
|
|
@@ -7323,7 +7539,7 @@ function ensureShipSshKeypair(stateDir) {
|
|
|
7323
7539
|
}
|
|
7324
7540
|
chmodSync(privateKeyPath, 384);
|
|
7325
7541
|
}
|
|
7326
|
-
if (!
|
|
7542
|
+
if (!existsSync16(publicKeyPath)) {
|
|
7327
7543
|
throw new SshKeyError(`${privateKeyPath} exists but ${publicKeyPath} does not \u2014 regenerate or restore it`);
|
|
7328
7544
|
}
|
|
7329
7545
|
const publicKey = readFileSync9(publicKeyPath, "utf8").trim();
|
|
@@ -7516,7 +7732,7 @@ function syncAllPassengerCheckouts(cfg) {
|
|
|
7516
7732
|
|
|
7517
7733
|
// src/tunnel.ts
|
|
7518
7734
|
import { spawn as spawn3 } from "child_process";
|
|
7519
|
-
import { existsSync as
|
|
7735
|
+
import { existsSync as existsSync17, mkdirSync as mkdirSync8, readFileSync as readFileSync10, writeFileSync as writeFileSync7, unlinkSync as unlinkSync3, readdirSync as readdirSync3 } from "fs";
|
|
7520
7736
|
import { randomInt } from "crypto";
|
|
7521
7737
|
import { join as join15 } from "path";
|
|
7522
7738
|
var DEFAULT_SPAWN_OPTIONS = { stdio: "ignore", detached: true };
|
|
@@ -7589,7 +7805,7 @@ function tunnelStatePath(stateDir, workspaceId) {
|
|
|
7589
7805
|
}
|
|
7590
7806
|
function readPersistedTunnel(stateDir, workspaceId) {
|
|
7591
7807
|
const path = tunnelStatePath(stateDir, workspaceId);
|
|
7592
|
-
if (!
|
|
7808
|
+
if (!existsSync17(path)) return void 0;
|
|
7593
7809
|
try {
|
|
7594
7810
|
return JSON.parse(readFileSync10(path, "utf8"));
|
|
7595
7811
|
} catch {
|
|
@@ -7697,7 +7913,7 @@ function syncPassengerTunnels(plans, relayHost, relayPort, privateKeyPath, state
|
|
|
7697
7913
|
}
|
|
7698
7914
|
function readAllPersistedWorkspaceIds(stateDir) {
|
|
7699
7915
|
const dir = join15(stateDir, "passengers");
|
|
7700
|
-
if (!
|
|
7916
|
+
if (!existsSync17(dir)) return [];
|
|
7701
7917
|
return readdirSync3(dir).filter((f) => f.endsWith(".json") && !f.endsWith("-sync.json")).map((f) => f.slice(0, -".json".length));
|
|
7702
7918
|
}
|
|
7703
7919
|
|
|
@@ -7818,7 +8034,7 @@ async function unregisterEndpoint(workspaceId, info, d) {
|
|
|
7818
8034
|
import { createServer as createHttpServer } from "http";
|
|
7819
8035
|
import { spawn as spawn4 } from "child_process";
|
|
7820
8036
|
import { randomBytes } from "crypto";
|
|
7821
|
-
import { existsSync as
|
|
8037
|
+
import { existsSync as existsSync18, mkdirSync as mkdirSync9, readFileSync as readFileSync11, writeFileSync as writeFileSync8, unlinkSync as unlinkSync4, readdirSync as readdirSync4 } from "fs";
|
|
7822
8038
|
import { join as join16 } from "path";
|
|
7823
8039
|
|
|
7824
8040
|
// src/secret-compare.ts
|
|
@@ -7912,7 +8128,7 @@ function syncDaemonStatePath(stateDir, workspaceId) {
|
|
|
7912
8128
|
}
|
|
7913
8129
|
function readPersistedSyncDaemon(stateDir, workspaceId) {
|
|
7914
8130
|
const path = syncDaemonStatePath(stateDir, workspaceId);
|
|
7915
|
-
if (!
|
|
8131
|
+
if (!existsSync18(path)) return void 0;
|
|
7916
8132
|
try {
|
|
7917
8133
|
return JSON.parse(readFileSync11(path, "utf8"));
|
|
7918
8134
|
} catch {
|
|
@@ -7943,7 +8159,7 @@ function waitForExit(pid, alive, timeoutMs = 5e3) {
|
|
|
7943
8159
|
}
|
|
7944
8160
|
function readAllPersistedSyncWorkspaceIds(stateDir) {
|
|
7945
8161
|
const dir = join16(stateDir, "passengers");
|
|
7946
|
-
if (!
|
|
8162
|
+
if (!existsSync18(dir)) return [];
|
|
7947
8163
|
const suffix = "-sync.json";
|
|
7948
8164
|
return readdirSync4(dir).filter((f) => f.endsWith(suffix)).map((f) => f.slice(0, -suffix.length));
|
|
7949
8165
|
}
|
|
@@ -8655,7 +8871,7 @@ if (command === "passenger-sync-daemon") {
|
|
|
8655
8871
|
if (host === "linux" && hasSystemd) {
|
|
8656
8872
|
const label = labelFor(CREW_HOME, "run");
|
|
8657
8873
|
const staleTimer = join18(homedir4(), ".config", "systemd", "user", `${label}.timer`);
|
|
8658
|
-
if (
|
|
8874
|
+
if (existsSync19(staleTimer)) {
|
|
8659
8875
|
process.stdout.write(
|
|
8660
8876
|
`removing stale run timer from before the persistent-service cutover: ${staleTimer}${dryRun ? " (dry run)" : ""}
|
|
8661
8877
|
`
|
|
@@ -8680,7 +8896,7 @@ if (command === "passenger-sync-daemon") {
|
|
|
8680
8896
|
const tracker = new Tracker(c, cfg.ship);
|
|
8681
8897
|
problems.push(...validateContract(tracker.contract).map((p) => `contract: ${p}`));
|
|
8682
8898
|
for (const r of await resolvedRepos(c)) {
|
|
8683
|
-
if (!
|
|
8899
|
+
if (!existsSync19(r.dir)) problems.push(`repo ${r.name}: ${r.dir} is missing on disk`);
|
|
8684
8900
|
if (!satisfies(host, r.config.platform)) problems.push(`repo ${r.name}: requires ${r.config.platform}, this host is ${host}`);
|
|
8685
8901
|
problems.push(...validateEffective(r.config).map((p) => `repo ${r.name}: ${p}`));
|
|
8686
8902
|
}
|
|
@@ -8698,7 +8914,7 @@ if (command === "passenger-sync-daemon") {
|
|
|
8698
8914
|
}
|
|
8699
8915
|
async function doctorFix(o) {
|
|
8700
8916
|
const routes = await Promise.all(routesInScope(cfg.routes, named).map((c) => gatherHealth({ route: c.route, enabled: c.enabled, hasRepos: reposOf(c).length > 0 }, () => routeProblems(c))));
|
|
8701
|
-
const unitExists = (p) =>
|
|
8917
|
+
const unitExists = (p) => existsSync19(p);
|
|
8702
8918
|
const crontab = o.host === "linux" ? spawnSync2("crontab", ["-l"], { encoding: "utf8" }).stdout ?? "" : "";
|
|
8703
8919
|
const schedulerAbsent = o.host !== "windows" ? !schedulerInstalled(planInstall(cfg.ship, CREW_HOME, o.host, detectSystemd(), "run"), unitExists, crontab) : true;
|
|
8704
8920
|
const plan = planFix({ routes, schedulerAbsent, host: o.host });
|
|
@@ -8855,6 +9071,36 @@ ${changed2.map((c) => ` - ${c}
|
|
|
8855
9071
|
} else if (outcome.stopped) {
|
|
8856
9072
|
remit.emit(`nothing stamped \u2014 ${outcome.stopped}`);
|
|
8857
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
|
+
}
|
|
8858
9104
|
if (outcome.externalClosures?.length) {
|
|
8859
9105
|
await applyExternalClosures(tracker, outcome.externalClosures, tracker.contract, remit, dryRun, target.dir);
|
|
8860
9106
|
}
|
|
@@ -8918,9 +9164,11 @@ ${changed2.map((c) => ` - ${c}
|
|
|
8918
9164
|
const actions = planWorktreeSweep(target, scoped, tracker.contract);
|
|
8919
9165
|
if (actions.length) {
|
|
8920
9166
|
remit.enter("worktree");
|
|
8921
|
-
const r = await applyWorktreeSweep(target.dir, actions, dryRun, remit);
|
|
9167
|
+
const r = await applyWorktreeSweep(target.dir, actions, dryRun, remit, repo.branch.remote);
|
|
8922
9168
|
remit.emit(`swept ${r.removed} worktree(s), kept ${r.keptBranches} branch(es)`, { step: "worktree" });
|
|
8923
9169
|
}
|
|
9170
|
+
const stale = planRemoteBranchCleanup(target.dir, repo.branch.remote, repo.branch.base, scoped, tracker.contract);
|
|
9171
|
+
if (stale.length) applyRemoteBranchCleanup(target.dir, repo.branch.remote, stale, dryRun, remit);
|
|
8924
9172
|
} catch (e) {
|
|
8925
9173
|
remit.warn(`worktree sweep failed: ${e.message}`, { step: "worktree" });
|
|
8926
9174
|
}
|
|
@@ -9574,7 +9822,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
9574
9822
|
`);
|
|
9575
9823
|
if (!dryRun) {
|
|
9576
9824
|
const resolvedPath = resolvedPathFor(cfg.ship.stateDir, target.route);
|
|
9577
|
-
const raw =
|
|
9825
|
+
const raw = existsSync19(resolvedPath) ? JSON.parse(readFileSync12(resolvedPath, "utf8")) : {};
|
|
9578
9826
|
raw.agentPersonas = personaResult.agentPersonas;
|
|
9579
9827
|
raw.agentSkills = skillResult.agentSkills;
|
|
9580
9828
|
mkdirSync10(dirOf(resolvedPath), { recursive: true });
|
|
@@ -9623,7 +9871,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
9623
9871
|
`);
|
|
9624
9872
|
if (!dryRun) {
|
|
9625
9873
|
const resolvedPath = resolvedPathFor(cfg.ship.stateDir, target.route);
|
|
9626
|
-
const raw =
|
|
9874
|
+
const raw = existsSync19(resolvedPath) ? JSON.parse(readFileSync12(resolvedPath, "utf8")) : {};
|
|
9627
9875
|
raw.agentSkills = result.agentSkills;
|
|
9628
9876
|
mkdirSync10(dirOf(resolvedPath), { recursive: true });
|
|
9629
9877
|
writeFileSync9(resolvedPath, `${JSON.stringify(raw, null, 2)}
|
|
@@ -9646,7 +9894,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
9646
9894
|
if (sub === "list" && !routeName) usage2();
|
|
9647
9895
|
if (sub === "add") {
|
|
9648
9896
|
try {
|
|
9649
|
-
const a = parseAddArgs(positional.slice(2), routeNamesOf(configText), (p) =>
|
|
9897
|
+
const a = parseAddArgs(positional.slice(2), routeNamesOf(configText), (p) => existsSync19(resolvePath(p.replace(/^~(?=\/|$)/, homedir4()))));
|
|
9650
9898
|
routeName = a.route;
|
|
9651
9899
|
addPath = a.path;
|
|
9652
9900
|
addName = a.name;
|
|
@@ -9661,7 +9909,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
9661
9909
|
}
|
|
9662
9910
|
const resolvedFile = resolvedPathFor(cfg.ship.stateDir, routeName);
|
|
9663
9911
|
let resolvedRemotes;
|
|
9664
|
-
if (
|
|
9912
|
+
if (existsSync19(resolvedFile)) {
|
|
9665
9913
|
try {
|
|
9666
9914
|
resolvedRemotes = JSON.parse(readFileSync12(resolvedFile, "utf8"));
|
|
9667
9915
|
} catch {
|
|
@@ -9678,14 +9926,14 @@ ${changed2.map((c) => ` - ${c}
|
|
|
9678
9926
|
for (const r of repos) {
|
|
9679
9927
|
const origin2 = originOf(r.dir);
|
|
9680
9928
|
const row = matchRepoName(origin2, resolvedRemotes);
|
|
9681
|
-
const note = !
|
|
9929
|
+
const note = !existsSync19(r.dir) ? "missing on disk" : row ? `Repos row: ${row}` : `no Repos row matches ${normalizeRemote(origin2) ?? "this checkout (no origin remote)"}`;
|
|
9682
9930
|
process.stdout.write(`${r.name} ${r.dir} ${note}
|
|
9683
9931
|
`);
|
|
9684
9932
|
}
|
|
9685
9933
|
break;
|
|
9686
9934
|
}
|
|
9687
9935
|
const target = resolvePath(addPath.replace(/^~(?=\/|$)/, homedir4()));
|
|
9688
|
-
const top =
|
|
9936
|
+
const top = existsSync19(target) ? gitOk(target, ["rev-parse", "--show-toplevel"]) : null;
|
|
9689
9937
|
if (!top) throw new ReposError(`${target} is not a git checkout`);
|
|
9690
9938
|
const origin = originOf(top);
|
|
9691
9939
|
const plan = planRepoAdd({
|
|
@@ -9733,7 +9981,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
9733
9981
|
const created = await rowTracker.createRepoRow(rowPayload);
|
|
9734
9982
|
process.stdout.write(`created Repos row ${String(rowPayload.name)} (${String(rowPayload.remote)}) ${created.id}
|
|
9735
9983
|
`);
|
|
9736
|
-
if (
|
|
9984
|
+
if (existsSync19(resolvedFile)) {
|
|
9737
9985
|
const raw = JSON.parse(readFileSync12(resolvedFile, "utf8"));
|
|
9738
9986
|
raw.repoNames = { ...raw.repoNames, [created.id]: String(rowPayload.name) };
|
|
9739
9987
|
raw.repoRemotes = { ...raw.repoRemotes, [created.id]: String(rowPayload.remote) };
|
|
@@ -9966,7 +10214,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
9966
10214
|
operator = await pickOperator(found.holds);
|
|
9967
10215
|
}
|
|
9968
10216
|
let previous;
|
|
9969
|
-
const hadPreviousFile =
|
|
10217
|
+
const hadPreviousFile = existsSync19(resolvedPath);
|
|
9970
10218
|
if (hadPreviousFile) {
|
|
9971
10219
|
try {
|
|
9972
10220
|
previous = JSON.parse(readFileSync12(resolvedPath, "utf8"));
|
|
@@ -10222,9 +10470,14 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
10222
10470
|
const { byRepo } = ticketsByRepo(route, terminal);
|
|
10223
10471
|
for (const r of await resolvedRepos(route)) {
|
|
10224
10472
|
const actions = planWorktreeSweep(r, byRepo.get(r.name) ?? [], tracker.contract);
|
|
10473
|
+
const stale = planRemoteBranchCleanup(r.dir, r.config.branch.remote, r.config.branch.base, byRepo.get(r.name) ?? [], tracker.contract);
|
|
10474
|
+
if (stale.length) {
|
|
10475
|
+
anything = true;
|
|
10476
|
+
applyRemoteBranchCleanup(r.dir, r.config.branch.remote, stale, dryRun, emit);
|
|
10477
|
+
}
|
|
10225
10478
|
if (!actions.length) continue;
|
|
10226
10479
|
anything = true;
|
|
10227
|
-
const res = await applyWorktreeSweep(r.dir, actions, dryRun, emit);
|
|
10480
|
+
const res = await applyWorktreeSweep(r.dir, actions, dryRun, emit, r.config.branch.remote);
|
|
10228
10481
|
emit.emit(
|
|
10229
10482
|
`swept ${res.removed} worktree(s) for ${r.name}, kept ${res.keptBranches} branch(es)`,
|
|
10230
10483
|
{ step: "worktree" }
|
|
@@ -10280,8 +10533,17 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
10280
10533
|
break;
|
|
10281
10534
|
}
|
|
10282
10535
|
gitOk(target.dir, ["worktree", "remove", "--force", wt]);
|
|
10283
|
-
if (branch)
|
|
10284
|
-
|
|
10536
|
+
if (branch) {
|
|
10537
|
+
gitOk(target.dir, ["branch", "-D", branch]);
|
|
10538
|
+
const { remote, base } = target.config.branch;
|
|
10539
|
+
if (remoteConfigured(target.dir, remote) && remoteBranchExists(target.dir, remote, branch)) {
|
|
10540
|
+
if (findKeyInRange(target.dir, `ISSUE-${n}`, `${remote}/${base}`)) {
|
|
10541
|
+
emit.emit(deleteRemoteBranch(target.dir, remote, branch) ? `deleted ${remote}/${branch}` : `could not delete ${remote}/${branch}`);
|
|
10542
|
+
} else {
|
|
10543
|
+
emit.warn(`${remote}/${branch} kept \u2014 nothing on ${remote}/${base} names ISSUE-${n} yet, so it may be the only copy`);
|
|
10544
|
+
}
|
|
10545
|
+
}
|
|
10546
|
+
} else emit.warn(`no branch found for ISSUE-${n} in ${target.name} \u2014 worktree removed, nothing to delete`);
|
|
10285
10547
|
emit.emit(`dropped ISSUE-${n} from ${target.name}`);
|
|
10286
10548
|
break;
|
|
10287
10549
|
}
|
package/package.json
CHANGED
|
@@ -191,6 +191,12 @@ since your last comment: an answer to a question, new direction, or a
|
|
|
191
191
|
comment is what clears you to act again, not the flag itself. Clearing
|
|
192
192
|
`needs_planning`/`needs_review` is always the operator's move, never
|
|
193
193
|
yours, whatever you find when you resume.
|
|
194
|
+
- **Before resuming in an existing worktree, run `crew sync`** (it fetches and
|
|
195
|
+
fast-forwards every worktree branch that tracks a remote, never merging or
|
|
196
|
+
rebasing) — another ship may have pushed to the branch since you left it.
|
|
197
|
+
If it reports your branch as diverged (local commits not on the remote AND
|
|
198
|
+
the remote has moved), stop: comment on the ticket with what it printed and
|
|
199
|
+
set `needs_info`; do not merge or rebase it yourself.
|
|
194
200
|
- A ticket QA has bounced back to you comes in as `in_progress`,
|
|
195
201
|
reassigned to your row, with a comment saying what still fails. Treat
|
|
196
202
|
that exactly like new direction from a hold: read the comment, fix what
|
|
@@ -520,6 +526,15 @@ if `report_type` is either of those.
|
|
|
520
526
|
part).
|
|
521
527
|
9. Set the ticket's `status` to `fixed` once you have verified it yourself,
|
|
522
528
|
and **clear `assignee_id`** — that pair is the hand-off to the QA lane.
|
|
529
|
+
**Push the branch first, in the same step:**
|
|
530
|
+
`git push --set-upstream <remote> <branch>` from the worktree (the remote is
|
|
531
|
+
`origin` unless the repo's `.crew.yaml` says otherwise; a repo with no remote
|
|
532
|
+
configured has nothing to push to, so say so in your comment and skip it).
|
|
533
|
+
Another ship may be the one that verifies and releases this ticket, and a
|
|
534
|
+
branch that never left this machine looks MISSING to it. A bounce back to
|
|
535
|
+
`in_progress` and the next `fixed` push again — a plain fast-forward, never
|
|
536
|
+
`--force`; if the push is rejected, someone else moved the branch, so stop
|
|
537
|
+
and say so on the ticket instead of forcing it.
|
|
523
538
|
Leave the worktree and branch exactly where they are, unmerged: QA boots
|
|
524
539
|
*your worktree* on its derived ports to test the fix, so removing it
|
|
525
540
|
would leave QA nothing to test. Your last progress comment is what QA
|
|
@@ -50,6 +50,12 @@ human review, so "probably fine" is not a pass.
|
|
|
50
50
|
Does the change actually do what the comment claims? Does it handle the
|
|
51
51
|
empty/error/permission path, or only the happy one? Does it touch
|
|
52
52
|
anything the ticket never mentioned?
|
|
53
|
+
**A branch on the remote is testable.** The builder may be on another ship:
|
|
54
|
+
the digest's `branch` column then reads `<name> (on origin only)` and no
|
|
55
|
+
worktree exists here yet. Cut one from the remote branch —
|
|
56
|
+
`git worktree add <the digest's worktree> <name>` from the ticket's repo
|
|
57
|
+
checkout (git creates the local tracking branch) — and carry on. Only
|
|
58
|
+
**MISSING** (gone locally and on the remote) means there is nothing to test.
|
|
53
59
|
5. **Run it.** The builder left its worktree in place for you: `cd` into it,
|
|
54
60
|
`nvm use` if the repository pins a version, and `eval` the **`ports`** hook
|
|
55
61
|
for this worktree's own ports (anything already listening on them is a dead
|
|
@@ -172,6 +172,12 @@ since your last comment: an answer to a question, new direction, or a
|
|
|
172
172
|
to leave such tickets alone, which — combined with the poll not waking
|
|
173
173
|
for them — left ISSUE-049 parked for hours mid-implementation with no
|
|
174
174
|
question outstanding, restartable only by someone commenting on it.
|
|
175
|
+
- **Before resuming in an existing worktree, run `crew sync`** (it fetches and
|
|
176
|
+
fast-forwards every worktree branch that tracks a remote, never merging or
|
|
177
|
+
rebasing) — another ship may have pushed to the branch since you left it.
|
|
178
|
+
If it reports your branch as diverged (local commits not on the remote AND
|
|
179
|
+
the remote has moved), stop: comment on the ticket with what it printed and
|
|
180
|
+
set `needs_info`; do not merge or rebase it yourself.
|
|
175
181
|
- A ticket QA has bounced back to you comes in as `in_progress`,
|
|
176
182
|
reassigned to your row, with a comment saying what still fails. Treat
|
|
177
183
|
that exactly like new direction from a hold: read the comment, fix what
|
|
@@ -501,6 +507,15 @@ if `report_type` is either of those.
|
|
|
501
507
|
part).
|
|
502
508
|
9. Set the ticket's `status` to `fixed` once you have verified it yourself,
|
|
503
509
|
and **clear `assignee_id`** — that pair is the hand-off to the QA lane.
|
|
510
|
+
**Push the branch first, in the same step:**
|
|
511
|
+
`git push --set-upstream <remote> <branch>` from the worktree (the remote is
|
|
512
|
+
`origin` unless the repo's `.crew.yaml` says otherwise; a repo with no remote
|
|
513
|
+
configured has nothing to push to, so say so in your comment and skip it).
|
|
514
|
+
Another ship may be the one that verifies and releases this ticket, and a
|
|
515
|
+
branch that never left this machine looks MISSING to it. A bounce back to
|
|
516
|
+
`in_progress` and the next `fixed` push again — a plain fast-forward, never
|
|
517
|
+
`--force`; if the push is rejected, someone else moved the branch, so stop
|
|
518
|
+
and say so on the ticket instead of forcing it.
|
|
504
519
|
Leave the worktree and branch exactly where they are, unmerged: QA boots
|
|
505
520
|
*your worktree* on its derived ports to test the fix, so removing it
|
|
506
521
|
would leave QA nothing to test. Your last progress comment is what QA
|