@tablation/crew 0.24.0 → 0.26.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/crew.example.yaml +9 -0
- package/dist/cli.js +174 -39
- package/package.json +1 -1
- package/prompts/default/personas/common.md +36 -3
- package/prompts/default/personas/lane-design.md +6 -0
- package/prompts/default/personas/lane-dev.md +8 -1
- package/prompts/default/personas/lane-qa.md +7 -0
- package/prompts/dev-qa/personas/common.md +36 -3
- package/prompts/dev-qa/personas/lane-dev.md +8 -1
- package/prompts/dev-qa/personas/lane-qa.md +7 -0
package/crew.example.yaml
CHANGED
|
@@ -76,6 +76,12 @@ routes:
|
|
|
76
76
|
|
|
77
77
|
worktreePrefix: "myproject-issue-"
|
|
78
78
|
|
|
79
|
+
# A repo may also be written as an object to name its worktree directory
|
|
80
|
+
# for this ship only (CREW-1504) — a template over {dir} {issue} {number}
|
|
81
|
+
# {key} {slug} {tag} {prefix} {role}. Outranks the repo's own
|
|
82
|
+
# `worktrees.name`, which outranks worktreePrefix; see docs/REPO_SPEC.md.
|
|
83
|
+
# My Product: { dir: "/home/me/src/my-product", worktreeName: "{dir}-{issue}" }
|
|
84
|
+
|
|
79
85
|
# Opt this route's ship into Host Passengers for this route's workspace
|
|
80
86
|
# (ISSUE-644): `crew connect` syncs this onto that workspace's own Ships
|
|
81
87
|
# row. Config-truth lives here, on the machine — never edit a Ships row
|
|
@@ -100,6 +106,9 @@ routes:
|
|
|
100
106
|
# Only needed for a self-hosted tracker.
|
|
101
107
|
# baseUrl: "https://app.tablation.com"
|
|
102
108
|
apiKey: "REPLACE_ME" # or omit if `ship.apiKey` covers this workspace too
|
|
109
|
+
# With no apiKey (and no apiKeyFile/apiKeyVar) crew reads CREW_API_KEY from
|
|
110
|
+
# its process environment. Under launchd that must be set in the job's own
|
|
111
|
+
# environment, not exported from an interactive shell.
|
|
103
112
|
|
|
104
113
|
# Where release state should be SHOWN is a fact about this machine and the
|
|
105
114
|
# person watching it, not about the code — so unlike every other hook,
|
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import { resolve as resolve4, dirname as dirname11, join as join20, basename as
|
|
4
|
+
import { resolve as resolve4, dirname as dirname11, join as join20, basename as basename8 } from "path";
|
|
5
5
|
import { fileURLToPath } from "url";
|
|
6
6
|
import { hostname as hostname2, homedir as homedir5 } from "os";
|
|
7
7
|
import { spawnSync as spawnSync3 } from "child_process";
|
|
@@ -84,6 +84,27 @@ var TOP_LEVEL = /* @__PURE__ */ new Set([
|
|
|
84
84
|
]);
|
|
85
85
|
var BRANCH_KEYS = /* @__PURE__ */ new Set(["base", "name", "push", "remote"]);
|
|
86
86
|
var PLACEHOLDER = /\{(key|number|slug|role|prefix|tag)\}/g;
|
|
87
|
+
var WORKTREE_PLACEHOLDERS = ["dir", "issue", "number", "key", "slug", "role", "prefix", "tag"];
|
|
88
|
+
function worktreeNameProblem(template) {
|
|
89
|
+
const valid = `Valid placeholders: ${WORKTREE_PLACEHOLDERS.map((p) => `{${p}}`).join(" ")}.`;
|
|
90
|
+
if (template.trim() === "") return `must be a non-empty string. ${valid}`;
|
|
91
|
+
if (/[/\\]/.test(template)) {
|
|
92
|
+
return "must not contain a path separator \u2014 a worktree is cut beside the checkout, not inside a directory of its own";
|
|
93
|
+
}
|
|
94
|
+
const stripped = template.replace(/\{([^{}]*)\}/g, (_m, name) => {
|
|
95
|
+
if (!WORKTREE_PLACEHOLDERS.includes(name)) return "\0";
|
|
96
|
+
return "";
|
|
97
|
+
});
|
|
98
|
+
if (stripped.includes("\0")) {
|
|
99
|
+
const bad = [...template.matchAll(/\{([^{}]*)\}/g)].map((m) => m[1] ?? "").filter((n) => !WORKTREE_PLACEHOLDERS.includes(n));
|
|
100
|
+
return `names unknown placeholder ${bad.map((b) => `{${b}}`).join(", ")}. ${valid}`;
|
|
101
|
+
}
|
|
102
|
+
if (/[{}]/.test(stripped)) return `has an unbalanced brace. ${valid}`;
|
|
103
|
+
if (!/\{(issue|number|key|slug|tag)\}/.test(template)) {
|
|
104
|
+
return "names no per-ticket placeholder \u2014 every ticket would get the same worktree. Use at least one of {issue} {number} {key} {slug} {tag}.";
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
87
108
|
var RELEASE_KEYS = /* @__PURE__ */ new Set([
|
|
88
109
|
"mode",
|
|
89
110
|
"ship",
|
|
@@ -132,7 +153,7 @@ function parseRepoConfig(text, file) {
|
|
|
132
153
|
rejectUnknown(raw.release?.ci, CI_KEYS, "release.ci", file);
|
|
133
154
|
rejectUnknown(raw.release?.verify, VERIFY_KEYS, "release.verify", file);
|
|
134
155
|
rejectUnknown(raw.branch, BRANCH_KEYS, "branch", file);
|
|
135
|
-
rejectUnknown(raw.worktrees, /* @__PURE__ */ new Set(["copy", "prefix"]), "worktrees", file);
|
|
156
|
+
rejectUnknown(raw.worktrees, /* @__PURE__ */ new Set(["copy", "prefix", "name"]), "worktrees", file);
|
|
136
157
|
rejectUnknown(raw.docs, /* @__PURE__ */ new Set(["triagePolicy", "designGuide"]), "docs", file);
|
|
137
158
|
if (raw.version === void 0) throw new RepoConfigError(`${file}: version is required`);
|
|
138
159
|
if (raw.version !== SPEC_VERSION) {
|
|
@@ -167,7 +188,13 @@ function parseRepoConfig(text, file) {
|
|
|
167
188
|
`${file}: worktrees.prefix ("${prefixRaw}") must not contain a path separator \u2014 a worktree is cut beside the checkout, not inside a directory of its own`
|
|
168
189
|
);
|
|
169
190
|
}
|
|
191
|
+
const nameRaw = raw.worktrees?.name;
|
|
192
|
+
if (nameRaw !== void 0) {
|
|
193
|
+
const problem = typeof nameRaw === "string" ? worktreeNameProblem(nameRaw) : "must be a string";
|
|
194
|
+
if (problem) throw new RepoConfigError(`${file}: worktrees.name ("${String(nameRaw)}") ${problem}`);
|
|
195
|
+
}
|
|
170
196
|
const worktrees2 = {
|
|
197
|
+
...nameRaw !== void 0 ? { name: String(nameRaw) } : {},
|
|
171
198
|
copy: (copyRaw ?? []).map((c) => String(c)),
|
|
172
199
|
// Empty means "not declared" here; the effective config fills it in, since
|
|
173
200
|
// only that layer knows the ship's setting and the checkout's directory.
|
|
@@ -358,6 +385,10 @@ function renderBranchName(template, ctx) {
|
|
|
358
385
|
}
|
|
359
386
|
});
|
|
360
387
|
}
|
|
388
|
+
function renderWorktreeName(template, ctx) {
|
|
389
|
+
const issue = (ctx.tag ?? ctx.key).toLowerCase();
|
|
390
|
+
return renderBranchName(template.replace(/\{dir\}/g, ctx.dir).replace(/\{issue\}/g, issue), ctx);
|
|
391
|
+
}
|
|
361
392
|
function defaultWorktreePrefix(dir) {
|
|
362
393
|
const name = basename(dir.replace(/[/\\]+$/, ""));
|
|
363
394
|
return name ? `${name}-issue-` : "issue-";
|
|
@@ -389,6 +420,10 @@ function resolveRepoConfig(repo, ship, dir) {
|
|
|
389
420
|
provenance[key] = "default";
|
|
390
421
|
return fallback;
|
|
391
422
|
};
|
|
423
|
+
if (ship?.worktrees?.nameOverride) {
|
|
424
|
+
if (repo?.worktrees.name) shadowed.push("worktrees.name");
|
|
425
|
+
provenance["worktrees.name"] = "ship";
|
|
426
|
+
} else if (repo?.worktrees.name) provenance["worktrees.name"] = "repo";
|
|
392
427
|
const hooks = {};
|
|
393
428
|
const labels = {};
|
|
394
429
|
for (const h of HOOK_NAMES) {
|
|
@@ -424,7 +459,8 @@ function resolveRepoConfig(repo, ship, dir) {
|
|
|
424
459
|
repo?.worktrees.prefix || void 0,
|
|
425
460
|
ship?.worktrees?.prefix,
|
|
426
461
|
defaultWorktreePrefix(dir)
|
|
427
|
-
)
|
|
462
|
+
),
|
|
463
|
+
...ship?.worktrees?.nameOverride || repo?.worktrees.name ? { name: ship?.worktrees?.nameOverride || repo?.worktrees.name } : {}
|
|
428
464
|
},
|
|
429
465
|
docs: repo?.docs ?? {},
|
|
430
466
|
branch: {
|
|
@@ -525,7 +561,8 @@ function effectiveBranchTemplate(cfg, prefix) {
|
|
|
525
561
|
if (cfg.provenance["branch.name"] !== "default") return cfg.branch.name;
|
|
526
562
|
return prefix ? "{prefix}-{number}" : "issue-{number}";
|
|
527
563
|
}
|
|
528
|
-
function effectiveWorktreeDirName(cfg, dir, branchName, number) {
|
|
564
|
+
function effectiveWorktreeDirName(cfg, dir, branchName, number, renderName) {
|
|
565
|
+
if (cfg.worktrees.name && renderName) return renderName(cfg.worktrees.name);
|
|
529
566
|
if (cfg.provenance["worktrees.prefix"] !== "default") return `${cfg.worktrees.prefix}${number}`;
|
|
530
567
|
return `${basename(dir.replace(/[/\\]+$/, ""))}-${branchName}`;
|
|
531
568
|
}
|
|
@@ -623,7 +660,12 @@ var ROUTE_KEYS = /* @__PURE__ */ new Set([
|
|
|
623
660
|
"promptSet",
|
|
624
661
|
"hostPassengers"
|
|
625
662
|
]);
|
|
626
|
-
|
|
663
|
+
function parseWorktreeName(v, where, file) {
|
|
664
|
+
const problem = typeof v === "string" ? worktreeNameProblem(v) : "must be a string";
|
|
665
|
+
if (problem) throw new ConfigError(`${file}: ${where}.worktreeName ("${String(v)}") ${problem}`);
|
|
666
|
+
return String(v);
|
|
667
|
+
}
|
|
668
|
+
var REPO_ENTRY_KEYS = /* @__PURE__ */ new Set(["dir", "hooks", "labels", "release", "branch", "hostPassengers", "worktreeName"]);
|
|
627
669
|
var BRANCH_OVERRIDE_KEYS = /* @__PURE__ */ new Set(["base", "name", "push", "remote"]);
|
|
628
670
|
var RELEASE_OVERRIDE_KEYS = /* @__PURE__ */ new Set([
|
|
629
671
|
"mode",
|
|
@@ -959,7 +1001,7 @@ function parseOneRoute(c, where, file, base, stateDir, raw, missing, crewHome) {
|
|
|
959
1001
|
const e = entry;
|
|
960
1002
|
rejectUnknownKeys(e, REPO_ENTRY_KEYS, `${where}.repos.${name}`, file);
|
|
961
1003
|
repoDirs[name] = expand(String(missing.req(e.dir, `${where}.repos.${name}.dir`) || ""), base);
|
|
962
|
-
if (e.hooks || e.labels || e.release || e.branch || e.hostPassengers !== void 0) {
|
|
1004
|
+
if (e.hooks || e.labels || e.release || e.branch || e.hostPassengers !== void 0 || e.worktreeName !== void 0) {
|
|
963
1005
|
repoOverrides[name] = {
|
|
964
1006
|
hooks: e.hooks,
|
|
965
1007
|
labels: e.labels,
|
|
@@ -969,7 +1011,8 @@ function parseOneRoute(c, where, file, base, stateDir, raw, missing, crewHome) {
|
|
|
969
1011
|
// shape as one that never had the key, and existing callers
|
|
970
1012
|
// compare this literally.
|
|
971
1013
|
...e.branch ? { branch: parseBranchOverride(e.branch, `${where}.repos.${name}`, file) } : {},
|
|
972
|
-
...e.hostPassengers !== void 0 ? { hostPassengers: e.hostPassengers === true } : {}
|
|
1014
|
+
...e.hostPassengers !== void 0 ? { hostPassengers: e.hostPassengers === true } : {},
|
|
1015
|
+
...e.worktreeName !== void 0 ? { worktreeName: parseWorktreeName(e.worktreeName, `${where}.repos.${name}`, file) } : {}
|
|
973
1016
|
};
|
|
974
1017
|
}
|
|
975
1018
|
} else {
|
|
@@ -1065,8 +1108,10 @@ function resolveApiKey(r) {
|
|
|
1065
1108
|
if (m) return (m[1] ?? "").trim().replace(/^["']|["']$/g, "");
|
|
1066
1109
|
}
|
|
1067
1110
|
}
|
|
1111
|
+
const fromEnv = process.env.CREW_API_KEY?.trim();
|
|
1112
|
+
if (fromEnv) return fromEnv;
|
|
1068
1113
|
throw new ConfigError(
|
|
1069
|
-
`route "${r.route}": no API key (
|
|
1114
|
+
`route "${r.route}": no API key (looked for, in order: apiKey; ${r.apiKeyVar ?? "VAR"} in ${r.apiKeyFile ?? "<unset>"}; CREW_API_KEY in the environment)`
|
|
1070
1115
|
);
|
|
1071
1116
|
}
|
|
1072
1117
|
var KEYCHAIN_ELIGIBLE_ROUTES = /* @__PURE__ */ new WeakSet();
|
|
@@ -1475,6 +1520,7 @@ var State = class _State {
|
|
|
1475
1520
|
const failed = `.deploy-failed-sha-${suffix}`;
|
|
1476
1521
|
const gateFailed = `.test-gate-failed-${suffix}`;
|
|
1477
1522
|
const gateReported = `.test-gate-reported-${suffix}`;
|
|
1523
|
+
const heldReported = `.release-held-${suffix}`;
|
|
1478
1524
|
const read = (f) => {
|
|
1479
1525
|
try {
|
|
1480
1526
|
return readFileSync3(this.path(f), "utf8").trim() || null;
|
|
@@ -1525,6 +1571,20 @@ var State = class _State {
|
|
|
1525
1571
|
rmSync(this.path(gateFailed), { force: true });
|
|
1526
1572
|
},
|
|
1527
1573
|
/** Whether the board was already told about this head's red gate. */
|
|
1574
|
+
heldReported: () => {
|
|
1575
|
+
try {
|
|
1576
|
+
return readFileSync3(this.path(heldReported), "utf8").replace(/\n$/, "") || null;
|
|
1577
|
+
} catch {
|
|
1578
|
+
return null;
|
|
1579
|
+
}
|
|
1580
|
+
},
|
|
1581
|
+
noteHeldReported: (signature) => {
|
|
1582
|
+
writeFileSync(this.path(heldReported), `${signature}
|
|
1583
|
+
`);
|
|
1584
|
+
},
|
|
1585
|
+
clearHeldReported: () => {
|
|
1586
|
+
rmSync(this.path(heldReported), { force: true });
|
|
1587
|
+
},
|
|
1528
1588
|
testGateReported: (sha) => read(gateReported) === sha,
|
|
1529
1589
|
noteTestGateReported: (sha) => {
|
|
1530
1590
|
writeFileSync(this.path(gateReported), `${sha}
|
|
@@ -3934,15 +3994,18 @@ function blockerInfoMap(tickets, extra = []) {
|
|
|
3934
3994
|
}
|
|
3935
3995
|
function computeBlockedIds(tickets, info) {
|
|
3936
3996
|
const blocked = /* @__PURE__ */ new Set();
|
|
3937
|
-
for (const t of tickets)
|
|
3938
|
-
const unresolved = (t.blocked_by ?? []).some((id) => {
|
|
3939
|
-
const status2 = info[id]?.status;
|
|
3940
|
-
return status2 !== void 0 && !RESOLVED_STATUSES.has(status2);
|
|
3941
|
-
});
|
|
3942
|
-
if (unresolved) blocked.add(t.id);
|
|
3943
|
-
}
|
|
3997
|
+
for (const t of tickets) if (unresolvedBlockers(t, info).length > 0) blocked.add(t.id);
|
|
3944
3998
|
return blocked;
|
|
3945
3999
|
}
|
|
4000
|
+
function unresolvedBlockers(t, info) {
|
|
4001
|
+
return (t.blocked_by ?? []).filter((id) => {
|
|
4002
|
+
const status2 = info[id]?.status;
|
|
4003
|
+
return status2 !== void 0 && !RESOLVED_STATUSES.has(status2);
|
|
4004
|
+
});
|
|
4005
|
+
}
|
|
4006
|
+
function describeUnresolved(t, info) {
|
|
4007
|
+
return unresolvedBlockers(t, info).map((id) => `${info[id]?.issue_tag ?? info[id]?.issue_id ?? "?"} (${info[id]?.status ?? "unknown"})`).join(", ");
|
|
4008
|
+
}
|
|
3946
4009
|
function sweepDiagnostics(tickets, info) {
|
|
3947
4010
|
const dangling = [];
|
|
3948
4011
|
const selfBlocked = [];
|
|
@@ -4258,9 +4321,12 @@ function buildingRoleHasWork(role, i) {
|
|
|
4258
4321
|
}
|
|
4259
4322
|
return { hasWork: false, reason: "nothing startable and no new comments" };
|
|
4260
4323
|
}
|
|
4324
|
+
function qaCandidates(i) {
|
|
4325
|
+
return withinWorkMode(notHeldByOtherShip(qaSlice(i.tickets), i), i.workModes).filter((t) => !isHeld(t, i.holds) && !i.blocked.has(t.id));
|
|
4326
|
+
}
|
|
4261
4327
|
function qaRoleHasWork(i) {
|
|
4262
4328
|
if (!i.seats.qa) return { hasWork: false, reason: "this ship does not crew that role" };
|
|
4263
|
-
const n =
|
|
4329
|
+
const n = qaCandidates(i).length;
|
|
4264
4330
|
return n > 0 ? { hasWork: true, reason: `${n} ticket(s) awaiting or in verification` } : { hasWork: false, reason: "nothing awaiting verification" };
|
|
4265
4331
|
}
|
|
4266
4332
|
function triageRoleHasWork(i) {
|
|
@@ -4276,7 +4342,7 @@ function roleHasWork(role, i) {
|
|
|
4276
4342
|
return buildingRoleHasWork(role, i);
|
|
4277
4343
|
}
|
|
4278
4344
|
function roleCandidates(role, i) {
|
|
4279
|
-
if (role === "qa") return
|
|
4345
|
+
if (role === "qa") return qaCandidates(i);
|
|
4280
4346
|
if (role === "triage") return triageSlice(i.tickets, i.seats.triage, i.workModes).filter((t) => !isHeld(t, i.holds));
|
|
4281
4347
|
if (role === "pair") return [];
|
|
4282
4348
|
const me = i.seats[role];
|
|
@@ -4610,19 +4676,35 @@ function qaDigest(i) {
|
|
|
4610
4676
|
const repo = i.dirFor ? dir ?? "**NO CHECKOUT**" : "";
|
|
4611
4677
|
return `| ${displayKey2(t)} | ${repo} | ${modeCell(t, i)} | ${t.status} | ${builtBy(t)} | ${who(t, i)} | ${filedBy(t, i)} | ${t.severity ?? "\u2014"} | ${t.priority ?? "\u2014"} | p${effectivePriority(t)} | ${branchCell(t)} | ${worktreeCell(t)} | ${t.updated_at.slice(0, 16)}Z | ${lastComment(i, t.id)} | ${n > 0 ? `**${n} new**` : "\u2014"} |`;
|
|
4612
4678
|
};
|
|
4679
|
+
const waiting = i.tickets.filter((t) => (t.status === "qa" || t.status === "fixed") && i.blocked.has(t.id));
|
|
4680
|
+
const takeable = (status2) => i.tickets.filter((t) => t.status === status2 && !i.blocked.has(t.id));
|
|
4681
|
+
const waitingTable = waiting.length === 0 ? "" : `
|
|
4682
|
+
### Waiting on blockers \u2014 not yours to verify yet
|
|
4683
|
+
|
|
4684
|
+
A \`fixed\` or \`qa\` ticket whose \`Blocked by\` names something not yet resolved
|
|
4685
|
+
(\`verified\`, \`closed_deployed\`, \`closed_wont_fix\` or \`closed_duplicate\`) is held out of
|
|
4686
|
+
verification: its change needs the blocker released first. It becomes yours on
|
|
4687
|
+
the first poll after the last blocker resolves. No one needs to change the ticket.
|
|
4688
|
+
|
|
4689
|
+
| ticket | status | blocked by |
|
|
4690
|
+
|---|---|---|
|
|
4691
|
+
${waiting.map((t) => `| ${displayKey2(t)} | ${t.status} | ${blockers(t, i)} |`).join("\n")}
|
|
4692
|
+
`;
|
|
4613
4693
|
return stream([
|
|
4614
4694
|
"## Current queue \u2014 built for you by the poll\n",
|
|
4615
4695
|
'\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',
|
|
4616
4696
|
'\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',
|
|
4617
4697
|
"\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",
|
|
4618
4698
|
"\n### Still in verification \u2014 yours, unfinished (take these first)\n",
|
|
4619
|
-
table(
|
|
4699
|
+
table(takeable("qa"), i, header, row),
|
|
4620
4700
|
"\n### Awaiting verification, in pick order\n",
|
|
4621
|
-
table(
|
|
4701
|
+
table(takeable("fixed"), i, header, row),
|
|
4702
|
+
...waitingTable ? [waitingTable] : []
|
|
4622
4703
|
]);
|
|
4623
4704
|
}
|
|
4624
4705
|
|
|
4625
4706
|
// src/ticket-branch.ts
|
|
4707
|
+
import { basename as basename4 } from "path";
|
|
4626
4708
|
function branchRenderer(t, role) {
|
|
4627
4709
|
const { tag, prefix } = ticketBranchContext(t);
|
|
4628
4710
|
return (template) => renderBranchName(template, { key: t.issue_id, title: t.title ?? void 0, role, tag, prefix });
|
|
@@ -4631,7 +4713,15 @@ function worktreeForTicket(dir, cfg, t, role) {
|
|
|
4631
4713
|
const { prefix } = ticketBranchContext(t);
|
|
4632
4714
|
const branchName = branchRenderer(t, role)(effectiveBranchTemplate(cfg, prefix));
|
|
4633
4715
|
const number = t.issue_id.replace(/^\D+/, "");
|
|
4634
|
-
|
|
4716
|
+
const { tag } = ticketBranchContext(t);
|
|
4717
|
+
return effectiveWorktreeDirName(cfg, dir, branchName, number, (template) => renderWorktreeName(template, {
|
|
4718
|
+
dir: basename4(dir.replace(/[/\\]+$/, "")),
|
|
4719
|
+
key: t.issue_id,
|
|
4720
|
+
title: t.title ?? void 0,
|
|
4721
|
+
role,
|
|
4722
|
+
tag,
|
|
4723
|
+
prefix
|
|
4724
|
+
}));
|
|
4635
4725
|
}
|
|
4636
4726
|
function branchTemplates(cfg, t) {
|
|
4637
4727
|
const { prefix } = ticketBranchContext(t);
|
|
@@ -4947,7 +5037,9 @@ function writeDigest(o, d, role, stateDir) {
|
|
|
4947
5037
|
const repoFor = (dir) => {
|
|
4948
5038
|
const hit = repoCache.get(dir);
|
|
4949
5039
|
if (hit) return hit;
|
|
4950
|
-
const
|
|
5040
|
+
const target = reposOf(o.route).find((r) => r.dir === dir);
|
|
5041
|
+
const nameOverride = target ? o.route.repoOverrides[target.name]?.worktreeName : void 0;
|
|
5042
|
+
const cfg = resolveRepoConfig(loadRepoConfig(dir), nameOverride ? { worktrees: { nameOverride } } : void 0, dir);
|
|
4951
5043
|
repoCache.set(dir, cfg);
|
|
4952
5044
|
return cfg;
|
|
4953
5045
|
};
|
|
@@ -5312,7 +5404,7 @@ import { join as join7, dirname as dirname5 } from "path";
|
|
|
5312
5404
|
import { createHash as createHash2 } from "crypto";
|
|
5313
5405
|
|
|
5314
5406
|
// src/environment.ts
|
|
5315
|
-
import { basename as
|
|
5407
|
+
import { basename as basename5 } from "path";
|
|
5316
5408
|
var API_KEY_VAR = "CREW_API_KEY";
|
|
5317
5409
|
function authorshipBullets(a, contract) {
|
|
5318
5410
|
if (!a) return [];
|
|
@@ -5363,8 +5455,16 @@ function repoSection(r, key, heading) {
|
|
|
5363
5455
|
const repo = r.config;
|
|
5364
5456
|
const branchTemplate = effectiveBranchTemplate(repo, repoPrefix(repo));
|
|
5365
5457
|
const branchName = branchExample(repo, key);
|
|
5366
|
-
const
|
|
5367
|
-
const
|
|
5458
|
+
const checkoutName = basename5(r.dir.replace(/[/\\]+$/, ""));
|
|
5459
|
+
const worktreeName = effectiveWorktreeDirName(
|
|
5460
|
+
repo,
|
|
5461
|
+
r.dir,
|
|
5462
|
+
branchName,
|
|
5463
|
+
key.replace(/^\D+/, ""),
|
|
5464
|
+
// The example ticket has no tag of its own, so `{issue}` is the key.
|
|
5465
|
+
(template) => renderWorktreeName(template, { dir: checkoutName, key, title: "Fix the widget", role: "dev", prefix: repoPrefix(repo) })
|
|
5466
|
+
);
|
|
5467
|
+
const worktreePattern = repo.worktrees.name ?? (repo.provenance["worktrees.prefix"] !== "default" ? `${repo.worktrees.prefix}<number>` : `${checkoutName}-<branch>`);
|
|
5368
5468
|
const lines = [
|
|
5369
5469
|
"",
|
|
5370
5470
|
heading,
|
|
@@ -6337,7 +6437,8 @@ async function guardRunHandoff(route, ship, tracker, role, workingId, log) {
|
|
|
6337
6437
|
const cfg = resolveRepoConfig(loadRepoConfig(target.dir), {
|
|
6338
6438
|
hooks: { ...route.hooks, ...o?.hooks },
|
|
6339
6439
|
labels: { ...route.labels, ...o?.labels },
|
|
6340
|
-
branch: { ...route.branch, ...o?.branch }
|
|
6440
|
+
branch: { ...route.branch, ...o?.branch },
|
|
6441
|
+
worktrees: { nameOverride: o?.worktreeName }
|
|
6341
6442
|
}, target.dir);
|
|
6342
6443
|
if (role === "qa") {
|
|
6343
6444
|
await retireRunWorktree(tracker, target.dir, cfg, t, role, seatId, ship.name, log);
|
|
@@ -6562,10 +6663,16 @@ function checkGuards(cwd, base = "main") {
|
|
|
6562
6663
|
detail: `working tree has ${changes.length} change(s)${untrackedNote}: ${names}${more}`
|
|
6563
6664
|
};
|
|
6564
6665
|
}
|
|
6666
|
+
function heldByBlockers(tickets, verifiedStatus, info) {
|
|
6667
|
+
if (!info) return [];
|
|
6668
|
+
return tickets.filter((t) => t.status === verifiedStatus && unresolvedBlockers(t, info).length > 0).map((ticket) => ({ ticket, blockers: describeUnresolved(ticket, info) }));
|
|
6669
|
+
}
|
|
6670
|
+
var describeHeld = (held) => held.map((h) => `${h.ticket.issue_tag ?? h.ticket.issue_id} held: blocked by ${h.blockers}`);
|
|
6565
6671
|
var CHANGELOG_LINE = /^Changelog:[ \t]*(.*)$/gm;
|
|
6566
6672
|
var BUMP_LINE = /^Bump:[ \t]*(major|minor|patch)\b/gim;
|
|
6567
|
-
function planMerge(cwd, tickets, contract, lastReleased, branchFor = (t) => branchForIssue(cwd, t.issue_id), base = "main") {
|
|
6568
|
-
const
|
|
6673
|
+
function planMerge(cwd, tickets, contract, lastReleased, branchFor = (t) => branchForIssue(cwd, t.issue_id), base = "main", blockerInfo) {
|
|
6674
|
+
const held = new Set(heldByBlockers(tickets, contract.statuses.verified, blockerInfo).map((h) => h.ticket.id));
|
|
6675
|
+
const verified = tickets.filter((t) => t.status === contract.statuses.verified && !held.has(t.id)).sort((a, b) => Number.parseInt(a.issue_id.replace(/^\D+/, ""), 10) - Number.parseInt(b.issue_id.replace(/^\D+/, ""), 10));
|
|
6569
6676
|
return verified.map((ticket) => {
|
|
6570
6677
|
const branch = branchFor(ticket);
|
|
6571
6678
|
if (!branch) {
|
|
@@ -6683,12 +6790,13 @@ function decideRelease(cwd, tickets, contract, opts = {}) {
|
|
|
6683
6790
|
const lastTag = latestReleaseTag(cwd, pattern, "HEAD");
|
|
6684
6791
|
const lastReleased = lastTag ? tagCommit(cwd, lastTag) : null;
|
|
6685
6792
|
const head = resolve2(cwd, "HEAD") ?? "";
|
|
6686
|
-
const merges = block ? [] : planMerge(cwd, tickets, contract, lastReleased, opts.branchFor ?? ((t) => branchForIssue(cwd, t.issue_id)), base);
|
|
6793
|
+
const merges = block ? [] : planMerge(cwd, tickets, contract, lastReleased, opts.branchFor ?? ((t) => branchForIssue(cwd, t.issue_id)), base, opts.blockerInfo);
|
|
6687
6794
|
const unreleasedCommits = lastReleased ? countCommits(cwd, `${lastReleased}..HEAD`) : 0;
|
|
6688
6795
|
const unreleasedWork = lastReleased ? (gitOk(cwd, ["log", "--format=%s", `${lastReleased}..HEAD`]) ?? "").split("\n").filter((l) => l && !RELEASE_COMMIT_SUBJECT.test(l)).length : 0;
|
|
6689
6796
|
return {
|
|
6690
6797
|
block,
|
|
6691
6798
|
merges,
|
|
6799
|
+
held: heldByBlockers(tickets, contract.statuses.verified, opts.blockerInfo),
|
|
6692
6800
|
head,
|
|
6693
6801
|
lastTag,
|
|
6694
6802
|
lastReleased,
|
|
@@ -7293,6 +7401,17 @@ function openReleaseCheckout(o) {
|
|
|
7293
7401
|
}
|
|
7294
7402
|
return { dir, branch, close };
|
|
7295
7403
|
}
|
|
7404
|
+
function reportHeld(o, held) {
|
|
7405
|
+
if (held.length === 0) {
|
|
7406
|
+
o.state?.clearHeldReported?.();
|
|
7407
|
+
return;
|
|
7408
|
+
}
|
|
7409
|
+
const lines = describeHeld(held);
|
|
7410
|
+
const signature = lines.join("\n");
|
|
7411
|
+
if (o.dryRun || o.state?.heldReported?.() === signature) return;
|
|
7412
|
+
for (const line of lines) o.emit.emit(line, { step: "release" });
|
|
7413
|
+
o.state?.noteHeldReported?.(signature);
|
|
7414
|
+
}
|
|
7296
7415
|
async function runRelease(o) {
|
|
7297
7416
|
const scoped = (msg) => o.scope ? `${o.scope}: ${msg}` : msg;
|
|
7298
7417
|
const tagPattern = o.repo.release.tagPattern ?? "v*";
|
|
@@ -7301,11 +7420,13 @@ async function runRelease(o) {
|
|
|
7301
7420
|
const decision = decideRelease(o.cwd, o.tickets, o.contract, {
|
|
7302
7421
|
tagPattern,
|
|
7303
7422
|
base: o.repo.branch.base,
|
|
7423
|
+
blockerInfo: o.blockerInfo,
|
|
7304
7424
|
// CREW-1364: a branch another ship built and pushed at `fixed` exists
|
|
7305
7425
|
// here only on the remote. Materialize it so the squash below has a ref.
|
|
7306
7426
|
// A dry run only looks.
|
|
7307
7427
|
branchFor: (t) => o.dryRun ? locateBranchForTicket(o.cwd, o.repo, t)?.branch ?? null : materializeBranchForTicket(o.cwd, o.repo, t)
|
|
7308
7428
|
});
|
|
7429
|
+
reportHeld(o, decision.held);
|
|
7309
7430
|
if (!fresh.ok) {
|
|
7310
7431
|
o.emit.error(scoped(`refusing to release \u2014 ${fresh.why}; a person has to reconcile the two`));
|
|
7311
7432
|
return { merged: [], conflicts: [], deployed: false, stopped: fresh.why, decision };
|
|
@@ -8974,7 +9095,7 @@ async function showLogEntry(route, entryId, opts = {}) {
|
|
|
8974
9095
|
|
|
8975
9096
|
// src/repos-cmd.ts
|
|
8976
9097
|
import { homedir as homedir3 } from "os";
|
|
8977
|
-
import { basename as
|
|
9098
|
+
import { basename as basename6, resolve as resolve3 } from "path";
|
|
8978
9099
|
import { isMap, isSeq, parseDocument, stringify as stringify2 } from "yaml";
|
|
8979
9100
|
var ReposError = class extends Error {
|
|
8980
9101
|
};
|
|
@@ -9046,7 +9167,7 @@ function configuredRepos(text, route, base) {
|
|
|
9046
9167
|
});
|
|
9047
9168
|
}
|
|
9048
9169
|
const dir = m.get("dir");
|
|
9049
|
-
return dir ? [{ name:
|
|
9170
|
+
return dir ? [{ name: basename6(String(dir)), dir: expandDir(String(dir), base) }] : [];
|
|
9050
9171
|
}
|
|
9051
9172
|
function planRepoAdd(i) {
|
|
9052
9173
|
const doc = parseDocument(i.text);
|
|
@@ -9058,7 +9179,7 @@ function planRepoAdd(i) {
|
|
|
9058
9179
|
throw new ReposError(`${target} is already configured on route ${i.route}`);
|
|
9059
9180
|
}
|
|
9060
9181
|
const matched = matchRepoName(i.origin, i.resolved);
|
|
9061
|
-
const name = matched ?? i.name ?? normalizeRemote(i.origin)?.split("/").pop() ??
|
|
9182
|
+
const name = matched ?? i.name ?? normalizeRemote(i.origin)?.split("/").pop() ?? basename6(target);
|
|
9062
9183
|
let warning;
|
|
9063
9184
|
if (!matched) {
|
|
9064
9185
|
const known = Object.values(i.resolved?.repoNames ?? {});
|
|
@@ -9222,14 +9343,14 @@ function schedulerInstalled(plan, exists, crontab) {
|
|
|
9222
9343
|
|
|
9223
9344
|
// src/worktree-sweep.ts
|
|
9224
9345
|
import { existsSync as existsSync15 } from "fs";
|
|
9225
|
-
import { basename as
|
|
9346
|
+
import { basename as basename7 } from "path";
|
|
9226
9347
|
function planWorktreeSweep(target, tickets, contract) {
|
|
9227
9348
|
const terminal = new Set(contract.statuses.resolved.filter((s) => s !== contract.statuses.verified));
|
|
9228
9349
|
const verified = contract.statuses.verified;
|
|
9229
9350
|
const byNumber = new Map(tickets.map((t) => [t.issue_id.replace(/^\D+/, ""), t]));
|
|
9230
9351
|
const actions = [];
|
|
9231
9352
|
for (const w of worktrees(target.dir)) {
|
|
9232
|
-
const m = /(?:^|-)(\d+)$/.exec(
|
|
9353
|
+
const m = /(?:^|-)(\d+)$/.exec(basename7(w.path));
|
|
9233
9354
|
if (!m) continue;
|
|
9234
9355
|
const ticket = byNumber.get(m[1]);
|
|
9235
9356
|
if (!ticket) continue;
|
|
@@ -10432,7 +10553,8 @@ async function buildEnvironment(route, ship, ticket) {
|
|
|
10432
10553
|
config: resolveRepoConfig(loadRepoConfig(t.dir), {
|
|
10433
10554
|
hooks: { ...route.hooks, ...o?.hooks },
|
|
10434
10555
|
labels: { ...route.labels, ...o?.labels },
|
|
10435
|
-
branch: { ...route.branch, ...o?.branch }
|
|
10556
|
+
branch: { ...route.branch, ...o?.branch },
|
|
10557
|
+
worktrees: { nameOverride: o?.worktreeName }
|
|
10436
10558
|
}, t.dir)
|
|
10437
10559
|
};
|
|
10438
10560
|
});
|
|
@@ -11027,7 +11149,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
11027
11149
|
// that declares its own `worktrees.prefix` shadows either, and
|
|
11028
11150
|
// `doctor` reports that the way it reports every other shadowed
|
|
11029
11151
|
// setting.
|
|
11030
|
-
worktrees: { prefix: shipWorktreePrefixFor(c) ?? await areaWorktreePrefix(c, id) }
|
|
11152
|
+
worktrees: { prefix: shipWorktreePrefixFor(c) ?? await areaWorktreePrefix(c, id), nameOverride: o?.worktreeName }
|
|
11031
11153
|
}, t.dir)
|
|
11032
11154
|
};
|
|
11033
11155
|
}));
|
|
@@ -11084,7 +11206,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
11084
11206
|
labels: { ...c.labels, ...o?.labels },
|
|
11085
11207
|
release: mergeRouteRelease(c.release, o?.release),
|
|
11086
11208
|
branch: { ...c.branch, ...o?.branch },
|
|
11087
|
-
worktrees: { prefix: shipWorktreePrefixFor(c) }
|
|
11209
|
+
worktrees: { prefix: shipWorktreePrefixFor(c), nameOverride: o?.worktreeName }
|
|
11088
11210
|
}, target.dir);
|
|
11089
11211
|
const problems = validateEffective(repo);
|
|
11090
11212
|
for (const p of problems) remit.warn(`${scope}: ${p}`);
|
|
@@ -11166,11 +11288,13 @@ ${changed2.map((c) => ` - ${c}
|
|
|
11166
11288
|
releaseModes
|
|
11167
11289
|
);
|
|
11168
11290
|
reportUnplaceable(c.route, unplaceable, tracker.contract.statuses.verified, remit);
|
|
11291
|
+
const blockerInfo = blockerInfoMap(all, await tracker.ticketsByIds(missingBlockerIds(all)));
|
|
11169
11292
|
const outcome = await runRelease({
|
|
11170
11293
|
cwd: target.dir,
|
|
11171
11294
|
repo,
|
|
11172
11295
|
contract: tracker.contract,
|
|
11173
11296
|
tickets,
|
|
11297
|
+
blockerInfo,
|
|
11174
11298
|
emit: remit,
|
|
11175
11299
|
scope,
|
|
11176
11300
|
state: state.release(scope),
|
|
@@ -13135,7 +13259,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
13135
13259
|
// `synthesis-tabl-123`. `worktreePrefix` above is kept for
|
|
13136
13260
|
// config-provenance display; this is the field that actually
|
|
13137
13261
|
// describes where a worktree lands by default.
|
|
13138
|
-
worktreeDirPattern: r.config.provenance["worktrees.prefix"] !== "default" ? `${r.config.worktrees.prefix}<number>` : `${
|
|
13262
|
+
worktreeDirPattern: r.config.worktrees.name ?? (r.config.provenance["worktrees.prefix"] !== "default" ? `${r.config.worktrees.prefix}<number>` : `${basename8(r.dir.replace(/[/\\]+$/, ""))}-<branch>`),
|
|
13139
13263
|
worktreeParent: resolvePath(r.dir, "..")
|
|
13140
13264
|
})),
|
|
13141
13265
|
waiting: waiting ? { ticket: waiting.ticket, since: waiting.since, streak: fairness.streak() } : null
|
|
@@ -13187,6 +13311,17 @@ watermark: ${state.watermark()}
|
|
|
13187
13311
|
`);
|
|
13188
13312
|
process.stdout.write(`releaser: ${r.name} \u2014 ${await releaserLine(statusTracker, route.route, r.name, r.config.release)}
|
|
13189
13313
|
`);
|
|
13314
|
+
}
|
|
13315
|
+
try {
|
|
13316
|
+
const open = await statusTracker.openTickets();
|
|
13317
|
+
const held = heldByBlockers(
|
|
13318
|
+
open,
|
|
13319
|
+
statusTracker.contract.statuses.verified,
|
|
13320
|
+
blockerInfoMap(open, await statusTracker.ticketsByIds(missingBlockerIds(open)))
|
|
13321
|
+
);
|
|
13322
|
+
for (const line of describeHeld(held)) process.stdout.write(`release: ${line}
|
|
13323
|
+
`);
|
|
13324
|
+
} catch {
|
|
13190
13325
|
}
|
|
13191
13326
|
if (waiting) {
|
|
13192
13327
|
process.stdout.write(
|
|
@@ -13223,10 +13358,10 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
|
|
|
13223
13358
|
);
|
|
13224
13359
|
for (const r of await resolvedRepos(route)) {
|
|
13225
13360
|
const { config } = r;
|
|
13226
|
-
const worktreePattern = config.provenance["worktrees.prefix"] !== "default" ? `${config.worktrees.prefix}<number>` : `${
|
|
13361
|
+
const worktreePattern = config.worktrees.name ?? (config.provenance["worktrees.prefix"] !== "default" ? `${config.worktrees.prefix}<number>` : `${basename8(r.dir.replace(/[/\\]+$/, ""))}-${effectiveBranchTemplate(config, void 0)}`);
|
|
13227
13362
|
process.stdout.write(
|
|
13228
13363
|
`${`repo ${r.name}:`.padEnd(19)}${r.dir}
|
|
13229
|
-
worktrees at ../${worktreePattern} (${config.provenance["worktrees.prefix"] ?? "default"}), branch ${config.branch.name}
|
|
13364
|
+
worktrees at ../${worktreePattern} (${config.provenance["worktrees.name"] ?? config.provenance["worktrees.prefix"] ?? "default"}), branch ${config.branch.name}
|
|
13230
13365
|
requires ${config.platform} \u2014 ${satisfies(host, config.platform) ? "OK" : "MISMATCH"}
|
|
13231
13366
|
`
|
|
13232
13367
|
);
|
package/package.json
CHANGED
|
@@ -684,9 +684,42 @@ post. They record who filed the ticket and from where. The block lists a column
|
|
|
684
684
|
only when this workspace has it, so a column it does not name is not yours to set.
|
|
685
685
|
|
|
686
686
|
Name the ticket you were working when you noticed it in the new ticket's
|
|
687
|
-
description. Only
|
|
688
|
-
|
|
689
|
-
|
|
687
|
+
description. Only add the new ticket to **your own ticket's** `blocked_by`
|
|
688
|
+
(the column named `blocked by` in the Environment section) if your own
|
|
689
|
+
ticket genuinely cannot proceed without the new one being fixed first —
|
|
690
|
+
merely having noticed it nearby is not a dependency. The dependency always
|
|
691
|
+
points from the ticket that waits to the ticket it waits on, never the
|
|
692
|
+
other way round.
|
|
693
|
+
|
|
694
|
+
### Work your ticket needs in another repository
|
|
695
|
+
|
|
696
|
+
Out-of-scope breakage and **in-scope work that lives somewhere else** are
|
|
697
|
+
different things. If your ticket's acceptance depends on a change in another
|
|
698
|
+
repository (or on a change this repository cannot make), do not ship a
|
|
699
|
+
stand-in for the missing piece and hand the ticket off. A stand-in whose UI,
|
|
700
|
+
docs or hints promise behaviour the dependency has not delivered is a defect
|
|
701
|
+
that QA passes and the release phase ships. Instead:
|
|
702
|
+
|
|
703
|
+
1. File that work as its own ticket in the owning repository's project (the
|
|
704
|
+
Environment section names the projects and repos; if the owning repo is
|
|
705
|
+
not listed there, file it against the project and say which repo in the
|
|
706
|
+
description), setting `repo_id`, `project_id`, `filed_by_id` and
|
|
707
|
+
`ship_id` as in the filing block, and naming your ticket in its
|
|
708
|
+
description.
|
|
709
|
+
2. Add the new ticket's id to **your own ticket's** `blocked_by`.
|
|
710
|
+
3. Post a comment on your ticket naming each dependency and what it must
|
|
711
|
+
deliver.
|
|
712
|
+
4. Set your ticket back to `accepted`. The poll's sweep parks it at
|
|
713
|
+
`blocked` and restores it to `accepted` once every blocker resolves.
|
|
714
|
+
In a `hybrid` project keep `assignee_id` as your own seat (an unassigned
|
|
715
|
+
hybrid ticket is off limits to every lane); in an `automatic` project
|
|
716
|
+
clear it as for any other re-queue.
|
|
717
|
+
5. Leave the branch and worktree in place, committed and pushed if they hold
|
|
718
|
+
work, so the lane that resumes the ticket continues from them.
|
|
719
|
+
|
|
720
|
+
Hand a ticket off as `fixed` only when the branch works against
|
|
721
|
+
dependencies that are already released. A hand-off comment that says "worth
|
|
722
|
+
separate tickets" for work the ticket needs is a hand-off that is not ready.
|
|
690
723
|
|
|
691
724
|
## Step 4 — you never merge, and you never deploy
|
|
692
725
|
|
|
@@ -82,6 +82,12 @@ stopping for this run, do the following instead:
|
|
|
82
82
|
is actually blocked on the operator's input, not as a substitute for
|
|
83
83
|
the ordinary ending above.
|
|
84
84
|
|
|
85
|
+
Before you post, check the comment: it must not name needed-but-unfiled
|
|
86
|
+
work. A design that depends on a change in another repository files that
|
|
87
|
+
ticket and links it in your ticket's `blocked_by` (see "Work your ticket
|
|
88
|
+
needs in another repository"), rather than listing it as a loose
|
|
89
|
+
follow-up.
|
|
90
|
+
|
|
85
91
|
Either way, stop this ticket's work for this run once the flag is set and
|
|
86
92
|
the comment posted. The operator reviews what you posted and takes it
|
|
87
93
|
from there:
|
|
@@ -24,4 +24,11 @@ release phase merges and ships it) or hands it back to you as `in_progress`
|
|
|
24
24
|
with a comment saying what still fails. Take that bounce seriously — it is
|
|
25
25
|
the only reading your work gets before it is deployed.
|
|
26
26
|
|
|
27
|
-
**
|
|
27
|
+
**Before you hand a ticket to QA, check the hand-off comment.** It must not
|
|
28
|
+
name needed-but-unfiled work: anything the ticket's acceptance depends on in
|
|
29
|
+
another repository is a filed ticket in your ticket's `blocked_by` (see "Work
|
|
30
|
+
your ticket needs in another repository"), not a line saying "worth separate
|
|
31
|
+
tickets". If it would name such work, the ticket goes back to `accepted`
|
|
32
|
+
instead of forward to `fixed`.
|
|
33
|
+
|
|
34
|
+
**No extra steps.** Otherwise follow the shared policy as written.
|
|
@@ -117,6 +117,13 @@ human review, so "probably fine" is not a pass.
|
|
|
117
117
|
Missing or stale docs are a reason to bounce the ticket back to the dev
|
|
118
118
|
seat, with a comment naming the page that needs the change. If the
|
|
119
119
|
repository names no such location, skip this check.
|
|
120
|
+
7b. **Check the hand-off for needed-but-unfiled work.** If the dev or design
|
|
121
|
+
comment names work that is needed in another repository (or that this
|
|
122
|
+
repository cannot do) and no ticket for it exists in the ticket's
|
|
123
|
+
`blocked_by`, that is a bounce: say so in your comment and ask for the
|
|
124
|
+
ticket and the `blocked_by` link. A shipped stand-in whose UI, docs or
|
|
125
|
+
hints promise behaviour the dependency has not yet delivered is a defect
|
|
126
|
+
even if every test passes. Bounce it like any other failure (step 8).
|
|
120
127
|
7a. **Ask whether the fix is covered.** A behaviour change with no test that
|
|
121
128
|
would catch its regression is worth naming in your comment; whether it
|
|
122
129
|
is worth bouncing the ticket over is your judgement, and depends on how
|
|
@@ -666,9 +666,42 @@ post. They record who filed the ticket and from where. The block lists a column
|
|
|
666
666
|
only when this workspace has it, so a column it does not name is not yours to set.
|
|
667
667
|
|
|
668
668
|
Name the ticket you were working when you noticed it in the new ticket's
|
|
669
|
-
description. Only
|
|
670
|
-
|
|
671
|
-
|
|
669
|
+
description. Only add the new ticket to **your own ticket's** `blocked_by`
|
|
670
|
+
(the column named `blocked by` in the Environment section) if your own
|
|
671
|
+
ticket genuinely cannot proceed without the new one being fixed first —
|
|
672
|
+
merely having noticed it nearby is not a dependency. The dependency always
|
|
673
|
+
points from the ticket that waits to the ticket it waits on, never the
|
|
674
|
+
other way round.
|
|
675
|
+
|
|
676
|
+
### Work your ticket needs in another repository
|
|
677
|
+
|
|
678
|
+
Out-of-scope breakage and **in-scope work that lives somewhere else** are
|
|
679
|
+
different things. If your ticket's acceptance depends on a change in another
|
|
680
|
+
repository (or on a change this repository cannot make), do not ship a
|
|
681
|
+
stand-in for the missing piece and hand the ticket off. A stand-in whose UI,
|
|
682
|
+
docs or hints promise behaviour the dependency has not delivered is a defect
|
|
683
|
+
that QA passes and the release phase ships. Instead:
|
|
684
|
+
|
|
685
|
+
1. File that work as its own ticket in the owning repository's project (the
|
|
686
|
+
Environment section names the projects and repos; if the owning repo is
|
|
687
|
+
not listed there, file it against the project and say which repo in the
|
|
688
|
+
description), setting `repo_id`, `project_id`, `filed_by_id` and
|
|
689
|
+
`ship_id` as in the filing block, and naming your ticket in its
|
|
690
|
+
description.
|
|
691
|
+
2. Add the new ticket's id to **your own ticket's** `blocked_by`.
|
|
692
|
+
3. Post a comment on your ticket naming each dependency and what it must
|
|
693
|
+
deliver.
|
|
694
|
+
4. Set your ticket back to `accepted`. The poll's sweep parks it at
|
|
695
|
+
`blocked` and restores it to `accepted` once every blocker resolves.
|
|
696
|
+
In a `hybrid` project keep `assignee_id` as your own seat (an unassigned
|
|
697
|
+
hybrid ticket is off limits to every lane); in an `automatic` project
|
|
698
|
+
clear it as for any other re-queue.
|
|
699
|
+
5. Leave the branch and worktree in place, committed and pushed if they hold
|
|
700
|
+
work, so the lane that resumes the ticket continues from them.
|
|
701
|
+
|
|
702
|
+
Hand a ticket off as `fixed` only when the branch works against
|
|
703
|
+
dependencies that are already released. A hand-off comment that says "worth
|
|
704
|
+
separate tickets" for work the ticket needs is a hand-off that is not ready.
|
|
672
705
|
|
|
673
706
|
## Step 4 — you never merge, and you never deploy
|
|
674
707
|
|
|
@@ -23,4 +23,11 @@ release phase merges and ships it) or hands it back to you as `in_progress`
|
|
|
23
23
|
with a comment saying what still fails. Take that bounce seriously — it is
|
|
24
24
|
the only reading your work gets before it is deployed.
|
|
25
25
|
|
|
26
|
-
**
|
|
26
|
+
**Before you hand a ticket to QA, check the hand-off comment.** It must not
|
|
27
|
+
name needed-but-unfiled work: anything the ticket's acceptance depends on in
|
|
28
|
+
another repository is a filed ticket in your ticket's `blocked_by` (see "Work
|
|
29
|
+
your ticket needs in another repository"), not a line saying "worth separate
|
|
30
|
+
tickets". If it would name such work, the ticket goes back to `accepted`
|
|
31
|
+
instead of forward to `fixed`.
|
|
32
|
+
|
|
33
|
+
**No extra steps.** Otherwise follow the shared policy as written.
|
|
@@ -104,6 +104,13 @@ human review, so "probably fine" is not a pass.
|
|
|
104
104
|
Missing or stale docs are a reason to bounce the ticket back to the dev
|
|
105
105
|
seat, with a comment naming the page that needs the change. If the
|
|
106
106
|
repository names no such location, skip this check.
|
|
107
|
+
7b. **Check the hand-off for needed-but-unfiled work.** If the dev or design
|
|
108
|
+
comment names work that is needed in another repository (or that this
|
|
109
|
+
repository cannot do) and no ticket for it exists in the ticket's
|
|
110
|
+
`blocked_by`, that is a bounce: say so in your comment and ask for the
|
|
111
|
+
ticket and the `blocked_by` link. A shipped stand-in whose UI, docs or
|
|
112
|
+
hints promise behaviour the dependency has not yet delivered is a defect
|
|
113
|
+
even if every test passes. Bounce it like any other failure (step 8).
|
|
107
114
|
7a. **Ask whether the fix is covered.** A behaviour change with no test that
|
|
108
115
|
would catch its regression is worth naming in your comment; whether it
|
|
109
116
|
is worth bouncing the ticket over is your judgement, and depends on how
|