@tablation/crew 0.14.1 → 0.15.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 +1683 -1660
- package/package.json +1 -1
- package/prompts/default/personas/common.md +56 -11
- package/prompts/dev-qa/personas/common.md +56 -11
package/dist/cli.js
CHANGED
|
@@ -2038,7 +2038,10 @@ function describeRelease(o, route) {
|
|
|
2038
2038
|
return {
|
|
2039
2039
|
level: "fail",
|
|
2040
2040
|
headline: `${route}: deploy FAILED`,
|
|
2041
|
-
detail: withHookTail(
|
|
2041
|
+
detail: withHookTail(
|
|
2042
|
+
"the version is merged to the base branch but not live \u2014 run `crew deploy` to retry; do not re-release",
|
|
2043
|
+
o.hookFailure
|
|
2044
|
+
)
|
|
2042
2045
|
};
|
|
2043
2046
|
}
|
|
2044
2047
|
if (o.stopped === "build failed") {
|
|
@@ -2570,1793 +2573,1808 @@ function detectSystemd() {
|
|
|
2570
2573
|
return !res.error;
|
|
2571
2574
|
}
|
|
2572
2575
|
|
|
2573
|
-
// src/
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2576
|
+
// src/git.ts
|
|
2577
|
+
import { execFileSync } from "child_process";
|
|
2578
|
+
import {
|
|
2579
|
+
existsSync as existsSync6,
|
|
2580
|
+
mkdirSync as mkdirSync4,
|
|
2581
|
+
openSync,
|
|
2582
|
+
closeSync,
|
|
2583
|
+
writeSync,
|
|
2584
|
+
statSync as statSync3,
|
|
2585
|
+
unlinkSync as unlinkSync2
|
|
2586
|
+
} from "fs";
|
|
2587
|
+
import { dirname as dirname4, basename as basename3 } from "path";
|
|
2588
|
+
var GitError = class extends Error {
|
|
2589
|
+
};
|
|
2590
|
+
function gitRaw(cwd, args) {
|
|
2591
|
+
try {
|
|
2592
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
2593
|
+
} catch (e) {
|
|
2594
|
+
const err = e;
|
|
2595
|
+
const why = [err.stderr, err.stdout].map((x) => (x ?? "").trim()).filter(Boolean).join(" / ");
|
|
2596
|
+
throw new GitError(`git ${args.join(" ")}: ${why || err.message.trim()}`);
|
|
2597
|
+
}
|
|
2579
2598
|
}
|
|
2580
|
-
function
|
|
2581
|
-
|
|
2599
|
+
function git(cwd, args) {
|
|
2600
|
+
try {
|
|
2601
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
2602
|
+
} catch (e) {
|
|
2603
|
+
const err = e;
|
|
2604
|
+
const why = [err.stderr, err.stdout].map((x) => (x ?? "").trim()).filter(Boolean).join(" / ");
|
|
2605
|
+
throw new GitError(`git ${args.join(" ")}: ${why || err.message.trim()}`);
|
|
2606
|
+
}
|
|
2582
2607
|
}
|
|
2583
|
-
function
|
|
2584
|
-
|
|
2585
|
-
|
|
2608
|
+
function gitOk(cwd, args) {
|
|
2609
|
+
try {
|
|
2610
|
+
return git(cwd, args);
|
|
2611
|
+
} catch {
|
|
2612
|
+
return null;
|
|
2613
|
+
}
|
|
2586
2614
|
}
|
|
2587
|
-
function
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
const
|
|
2591
|
-
|
|
2592
|
-
const minutes = Math.round(ageMs / 6e4);
|
|
2593
|
-
return {
|
|
2594
|
-
kind: "release_stale",
|
|
2595
|
-
key: "release_stale",
|
|
2596
|
-
message: `This ship has not run a release cycle for ${minutes} minute${minutes === 1 ? "" : "s"} \u2014 check its release timer`,
|
|
2597
|
-
since: new Date(lastRunMs + limitMs).toISOString()
|
|
2598
|
-
};
|
|
2615
|
+
function commitDate(cwd, sha) {
|
|
2616
|
+
const out = gitOk(cwd, ["show", "-s", "--format=%cI", sha]);
|
|
2617
|
+
if (!out) return null;
|
|
2618
|
+
const t = new Date(out);
|
|
2619
|
+
return Number.isNaN(t.getTime()) ? null : t.toISOString();
|
|
2599
2620
|
}
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
return
|
|
2621
|
+
var currentBranch = (cwd) => git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
2622
|
+
var headSha = (cwd) => git(cwd, ["rev-parse", "HEAD"]);
|
|
2623
|
+
function status(cwd) {
|
|
2624
|
+
return gitRaw(cwd, ["status", "--porcelain"]).split("\n").filter((l) => l.length > 3).map((l) => ({
|
|
2625
|
+
code: l.slice(0, 2),
|
|
2626
|
+
path: l.slice(3),
|
|
2627
|
+
untracked: l.startsWith("??")
|
|
2628
|
+
}));
|
|
2604
2629
|
}
|
|
2605
|
-
function
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
data: { attention: [`ship:${item.kind}`], title: item.message }
|
|
2609
|
-
});
|
|
2630
|
+
function branches(cwd, pattern) {
|
|
2631
|
+
const out = git(cwd, ["branch", "--list", pattern, "--format=%(refname:short)"]);
|
|
2632
|
+
return out ? out.split("\n").filter(Boolean) : [];
|
|
2610
2633
|
}
|
|
2611
|
-
|
|
2612
|
-
const
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
store.persist(next);
|
|
2616
|
-
if (transitions(previous, next).length === 0) return false;
|
|
2617
|
-
announce(o.emit, item);
|
|
2618
|
-
if (o.route && o.ship) {
|
|
2619
|
-
await notify(o.route, o.ship, { level: "warn", headline: item.message }, o.emit);
|
|
2634
|
+
function branchForIssue(cwd, issueKey, templates = { name: "issue-{number}" }, render2 = (t) => t.replace(/\{number\}/g, issueKey.replace(/^\D+/, ""))) {
|
|
2635
|
+
for (const c of branchCandidates(issueKey, templates, render2)) {
|
|
2636
|
+
const found = branches(cwd, c);
|
|
2637
|
+
if (found.length) return found[0];
|
|
2620
2638
|
}
|
|
2621
|
-
return
|
|
2639
|
+
return null;
|
|
2622
2640
|
}
|
|
2623
|
-
function
|
|
2624
|
-
const
|
|
2625
|
-
const
|
|
2626
|
-
const
|
|
2627
|
-
|
|
2641
|
+
function branchCandidates(issueKey, templates, render2) {
|
|
2642
|
+
const num = issueKey.replace(/^\D+/, "");
|
|
2643
|
+
const unpadded = String(Number.parseInt(num, 10));
|
|
2644
|
+
const candidates = [];
|
|
2645
|
+
for (const template of [templates.name, templates.push].filter(Boolean)) {
|
|
2646
|
+
const exact = render2(template);
|
|
2647
|
+
candidates.push(exact);
|
|
2648
|
+
if (num !== unpadded) candidates.push(exact.replace(num, unpadded));
|
|
2649
|
+
if (template.includes("{slug}")) {
|
|
2650
|
+
const prefix = render2(template.slice(0, template.indexOf("{slug}")));
|
|
2651
|
+
if (prefix) candidates.push(`${prefix}*`);
|
|
2652
|
+
}
|
|
2653
|
+
}
|
|
2654
|
+
candidates.push(`issue-${num}`);
|
|
2655
|
+
if (num !== unpadded) candidates.push(`issue-${unpadded}`);
|
|
2656
|
+
return [...new Set(candidates)];
|
|
2628
2657
|
}
|
|
2629
|
-
function
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
const
|
|
2634
|
-
if (
|
|
2635
|
-
if (!/^[A-Za-z0-9._+-]+$/.test(word)) return null;
|
|
2636
|
-
if (SHELL_WORDS.has(word)) return null;
|
|
2637
|
-
return word;
|
|
2658
|
+
function remoteBranchForIssue(cwd, remote, issueKey, templates = { name: "issue-{number}" }, render2 = (t) => t.replace(/\{number\}/g, issueKey.replace(/^\D+/, ""))) {
|
|
2659
|
+
const prefix = `refs/remotes/${remote}/`;
|
|
2660
|
+
for (const c of branchCandidates(issueKey, templates, render2)) {
|
|
2661
|
+
const out = gitOk(cwd, ["for-each-ref", "--format=%(refname)", `${prefix}${c}`]);
|
|
2662
|
+
const hit = out?.split("\n").filter((r) => r.startsWith(prefix) && r !== `${prefix}HEAD`)[0];
|
|
2663
|
+
if (hit) return hit.slice(prefix.length);
|
|
2638
2664
|
}
|
|
2639
2665
|
return null;
|
|
2640
2666
|
}
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
"else",
|
|
2645
|
-
"elif",
|
|
2646
|
-
"fi",
|
|
2647
|
-
"for",
|
|
2648
|
-
"while",
|
|
2649
|
-
"until",
|
|
2650
|
-
"do",
|
|
2651
|
-
"done",
|
|
2652
|
-
"case",
|
|
2653
|
-
"esac",
|
|
2654
|
-
"cd",
|
|
2655
|
-
"echo",
|
|
2656
|
-
"export",
|
|
2657
|
-
"set",
|
|
2658
|
-
"exit",
|
|
2659
|
-
"test",
|
|
2660
|
-
"true",
|
|
2661
|
-
"false",
|
|
2662
|
-
"source",
|
|
2663
|
-
".",
|
|
2664
|
-
"eval",
|
|
2665
|
-
"exec",
|
|
2666
|
-
"ok"
|
|
2667
|
-
]);
|
|
2668
|
-
|
|
2669
|
-
// src/board-lock.ts
|
|
2670
|
-
import { StaleWriteError } from "@tablation/client";
|
|
2671
|
-
function releaseLockScope(route, repo) {
|
|
2672
|
-
return `${route}/${repo}`;
|
|
2673
|
-
}
|
|
2674
|
-
async function acquireBoardLock(client2, modelId, scope, holderLabel, ttlMs) {
|
|
2675
|
-
if (!modelId) return { ok: true, release: async () => {
|
|
2676
|
-
} };
|
|
2677
|
-
const filters = JSON.stringify([{ columnName: "scope", operator: "EQ", value: scope }]);
|
|
2678
|
-
const rows = await client2.list(modelId, { filters, limit: 1 });
|
|
2679
|
-
const row = rows[0];
|
|
2680
|
-
if (!row) return { ok: false, reason: "missing" };
|
|
2681
|
-
const age = Date.now() - new Date(row.updated_at).getTime();
|
|
2682
|
-
const free = !row.holder || row.holder === holderLabel || age > ttlMs;
|
|
2683
|
-
if (!free) return { ok: false, heldBy: row.holder ?? void 0, reason: "held" };
|
|
2684
|
-
let claimed;
|
|
2685
|
-
try {
|
|
2686
|
-
claimed = await client2.update(modelId, row.id, { holder: holderLabel }, row.updated_at);
|
|
2687
|
-
} catch (e) {
|
|
2688
|
-
if (e instanceof StaleWriteError) return { ok: false, reason: "contended" };
|
|
2689
|
-
throw e;
|
|
2667
|
+
function materializeRemoteBranch(cwd, remote, branch) {
|
|
2668
|
+
if (!remoteBranchExists(cwd, remote, branch)) {
|
|
2669
|
+
return gitOk(cwd, ["rev-parse", "-q", "--verify", `refs/heads/${branch}`]) !== null;
|
|
2690
2670
|
}
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
release: async () => {
|
|
2694
|
-
try {
|
|
2695
|
-
await client2.update(modelId, row.id, { holder: "" }, claimed.updated_at);
|
|
2696
|
-
} catch {
|
|
2697
|
-
}
|
|
2698
|
-
}
|
|
2699
|
-
};
|
|
2700
|
-
}
|
|
2701
|
-
async function lockRowState(client2, modelId, scope) {
|
|
2702
|
-
const rows = await client2.list(modelId, { filters: scopeFilter(scope), limit: 1 });
|
|
2703
|
-
const row = rows[0];
|
|
2704
|
-
if (!row) return { exists: false };
|
|
2705
|
-
return { exists: true, ...row.holder ? { holder: row.holder, since: row.updated_at } : {} };
|
|
2706
|
-
}
|
|
2707
|
-
var scopeFilter = (scope) => JSON.stringify([{ columnName: "scope", operator: "EQ", value: scope }]);
|
|
2708
|
-
async function ensureLockRows(client2, modelId, scopes) {
|
|
2709
|
-
const created = [];
|
|
2710
|
-
for (const scope of scopes) {
|
|
2711
|
-
const filters = scopeFilter(scope);
|
|
2712
|
-
if ((await client2.list(modelId, { filters, limit: 1 })).length > 0) continue;
|
|
2713
|
-
let mine;
|
|
2714
|
-
try {
|
|
2715
|
-
mine = await client2.create(modelId, { scope, holder: "" });
|
|
2716
|
-
} catch (e) {
|
|
2717
|
-
const status2 = e.status;
|
|
2718
|
-
if (status2 === 409 || status2 === 400) {
|
|
2719
|
-
if ((await client2.list(modelId, { filters, limit: 1 })).length > 0) continue;
|
|
2720
|
-
}
|
|
2721
|
-
throw e;
|
|
2722
|
-
}
|
|
2723
|
-
const all = await client2.list(modelId, { filters, limit: 20 });
|
|
2724
|
-
const keeper = [...all].sort((a, b) => (a.created_at ?? "").localeCompare(b.created_at ?? "") || a.id.localeCompare(b.id))[0];
|
|
2725
|
-
if (keeper && keeper.id !== mine.id) {
|
|
2726
|
-
await client2.remove(modelId, mine.id).catch(() => {
|
|
2727
|
-
});
|
|
2728
|
-
continue;
|
|
2729
|
-
}
|
|
2730
|
-
created.push(scope);
|
|
2671
|
+
if (gitOk(cwd, ["rev-parse", "-q", "--verify", `refs/heads/${branch}`]) === null) {
|
|
2672
|
+
return gitOk(cwd, ["branch", "--track", branch, `${remote}/${branch}`]) !== null;
|
|
2731
2673
|
}
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
// src/tracker.ts
|
|
2736
|
-
function ticketBranchContext(t) {
|
|
2737
|
-
const tag = t.issue_tag ?? void 0;
|
|
2738
|
-
if (!tag) return {};
|
|
2739
|
-
const prefix = t.project_issue_prefix ? t.project_issue_prefix.toLowerCase() : void 0;
|
|
2740
|
-
return { tag, prefix };
|
|
2674
|
+
gitOk(cwd, ["fetch", "--quiet", remote, `${branch}:${branch}`]);
|
|
2675
|
+
return true;
|
|
2741
2676
|
}
|
|
2742
|
-
function
|
|
2743
|
-
|
|
2677
|
+
function deleteRemoteBranch(cwd, remote, branch) {
|
|
2678
|
+
if (!remoteBranchExists(cwd, remote, branch)) return true;
|
|
2679
|
+
return gitOk(cwd, ["push", remote, "--delete", branch]) !== null;
|
|
2744
2680
|
}
|
|
2745
|
-
function
|
|
2746
|
-
|
|
2681
|
+
function worktreeForNumber(cwd, num) {
|
|
2682
|
+
const re = new RegExp(`(?:^|-)${num}$`);
|
|
2683
|
+
return worktrees(cwd).find((w) => re.test(basename3(w.path))) ?? null;
|
|
2747
2684
|
}
|
|
2748
|
-
var
|
|
2749
|
-
var
|
|
2750
|
-
var
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2685
|
+
var commitBodies = (cwd, range) => gitOk(cwd, ["log", range, "--format=%B"]) ?? "";
|
|
2686
|
+
var resolve2 = (cwd, ref) => gitOk(cwd, ["rev-parse", "-q", "--verify", `${ref}^{commit}`]);
|
|
2687
|
+
var countCommits = (cwd, range) => Number.parseInt(gitOk(cwd, ["rev-list", "--count", range]) ?? "0", 10);
|
|
2688
|
+
function latestReleaseTag(cwd, pattern = "v*", ref = "HEAD") {
|
|
2689
|
+
return gitOk(cwd, ["describe", "--tags", "--abbrev=0", "--match", pattern, ref]);
|
|
2690
|
+
}
|
|
2691
|
+
function firstReleaseTagContaining(cwd, sha, pattern = "v*") {
|
|
2692
|
+
const out = gitOk(cwd, ["tag", "--contains", sha, "--list", pattern, "--sort=creatordate"]);
|
|
2693
|
+
return out?.split("\n").map((l) => l.trim()).find(Boolean) ?? null;
|
|
2694
|
+
}
|
|
2695
|
+
var tagCommit = (cwd, tag) => resolve2(cwd, tag);
|
|
2696
|
+
function createReleaseTag(cwd, tag, sha, message) {
|
|
2697
|
+
git(cwd, ["tag", "-a", tag, sha, "-m", message]);
|
|
2698
|
+
}
|
|
2699
|
+
var tagExists = (cwd, tag) => gitOk(cwd, ["rev-parse", "-q", "--verify", `refs/tags/${tag}`]) !== null;
|
|
2700
|
+
function pushTag(cwd, remote, tag) {
|
|
2701
|
+
git(cwd, ["push", remote, tag]);
|
|
2702
|
+
}
|
|
2703
|
+
function fetchRemote(cwd, remote = "origin") {
|
|
2704
|
+
return gitOk(cwd, ["fetch", "--prune", "--quiet", remote]) !== null;
|
|
2705
|
+
}
|
|
2706
|
+
function remoteConfigured(cwd, remote = "origin") {
|
|
2707
|
+
return gitOk(cwd, ["remote", "get-url", remote]) !== null;
|
|
2708
|
+
}
|
|
2709
|
+
function remoteBranchExists(cwd, remote, branch) {
|
|
2710
|
+
return gitOk(cwd, ["rev-parse", "-q", "--verify", `refs/remotes/${remote}/${branch}`]) !== null;
|
|
2711
|
+
}
|
|
2712
|
+
function findKeyInRange(cwd, key, range) {
|
|
2713
|
+
return keyCommitsInRange(cwd, key, range)[0] ?? null;
|
|
2714
|
+
}
|
|
2715
|
+
function keyCommitsInRange(cwd, key, range) {
|
|
2716
|
+
const keys = Array.isArray(key) ? key : [key];
|
|
2717
|
+
const escaped = keys.map((k) => k.replace(/[.[\]{}()*+?^$|\\]/g, "\\$&"));
|
|
2718
|
+
const pattern = `(^|[^0-9A-Za-z_-])(${escaped.join("|")})([^0-9]|$)`;
|
|
2719
|
+
const out = gitOk(cwd, ["log", range, "--format=%H %s", "--extended-regexp", `--grep=${pattern}`]);
|
|
2720
|
+
if (!out) return [];
|
|
2721
|
+
return out.split("\n").map((l) => l.split(" ")[0]).filter(Boolean);
|
|
2722
|
+
}
|
|
2723
|
+
function patchIdOf(cwd, from, to) {
|
|
2724
|
+
try {
|
|
2725
|
+
const diff = execFileSync("git", ["diff", from, to], { cwd, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
|
|
2726
|
+
if (!diff) return null;
|
|
2727
|
+
const out = execFileSync("git", ["patch-id", "--stable"], { cwd, encoding: "utf8", input: diff });
|
|
2728
|
+
return out.split(" ")[0]?.trim() || null;
|
|
2729
|
+
} catch {
|
|
2730
|
+
return null;
|
|
2786
2731
|
}
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
async openTickets() {
|
|
2797
|
-
const c = this.contract;
|
|
2798
|
-
const filters = [
|
|
2799
|
-
{ columnName: c.columns.status, operator: "IN", value: c.statuses.open }
|
|
2800
|
-
];
|
|
2801
|
-
const areaId = this.route.resolved?.areaId;
|
|
2802
|
-
if (areaId) filters.push({ columnName: c.columns.slice, operator: "EQ", value: areaId });
|
|
2803
|
-
return this.client.records.list(this.models.issues, {
|
|
2804
|
-
filters: encodeFilters(filters),
|
|
2805
|
-
limit: 500
|
|
2806
|
-
});
|
|
2732
|
+
}
|
|
2733
|
+
function findSquashOfBranch(cwd, key, branch, base, range) {
|
|
2734
|
+
const candidates = keyCommitsInRange(cwd, key, range);
|
|
2735
|
+
if (candidates.length === 0) return null;
|
|
2736
|
+
const mb = gitOk(cwd, ["merge-base", base, branch]);
|
|
2737
|
+
const branchPatch = mb ? patchIdOf(cwd, mb, branch) : null;
|
|
2738
|
+
for (const sha of candidates) {
|
|
2739
|
+
if (gitOk(cwd, ["diff", "--quiet", branch, sha]) !== null) return sha;
|
|
2740
|
+
if (branchPatch && gitOk(cwd, ["rev-parse", "--verify", "-q", `${sha}^`]) !== null && patchIdOf(cwd, `${sha}^`, sha) === branchPatch) return sha;
|
|
2807
2741
|
}
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
if (!statuses.length) return [];
|
|
2829
|
-
const filters = [
|
|
2830
|
-
{ columnName: c.columns.status, operator: "IN", value: statuses }
|
|
2831
|
-
];
|
|
2832
|
-
const areaId = this.route.resolved?.areaId;
|
|
2833
|
-
if (areaId) filters.push({ columnName: c.columns.slice, operator: "EQ", value: areaId });
|
|
2834
|
-
const params = {
|
|
2835
|
-
filters: encodeFilters(filters),
|
|
2836
|
-
limit: 500,
|
|
2837
|
-
sort: encodeSort([{ columnName: c.columns.updatedAt, direction: "desc" }])
|
|
2742
|
+
return null;
|
|
2743
|
+
}
|
|
2744
|
+
function isAncestor(cwd, ancestor, descendant) {
|
|
2745
|
+
return gitOk(cwd, ["merge-base", "--is-ancestor", ancestor, descendant]) !== null;
|
|
2746
|
+
}
|
|
2747
|
+
function fileAtRef(cwd, ref, path) {
|
|
2748
|
+
return gitOk(cwd, ["show", `${ref}:${path}`]);
|
|
2749
|
+
}
|
|
2750
|
+
function findKeyOnBase(cwd, key, remote, base, since2) {
|
|
2751
|
+
const range = since2 ? `${since2}..${remote}/${base}` : `${remote}/${base}`;
|
|
2752
|
+
return findKeyInRange(cwd, key, range);
|
|
2753
|
+
}
|
|
2754
|
+
function detectClosureHeuristically(cwd, key, pushedBranch, remote = "origin", base = "main", since2, aliases = []) {
|
|
2755
|
+
const merged = findKeyOnBase(cwd, [key, ...aliases], remote, base, since2);
|
|
2756
|
+
if (merged) {
|
|
2757
|
+
return {
|
|
2758
|
+
state: "merged",
|
|
2759
|
+
confidence: "heuristic",
|
|
2760
|
+
mergedAt: merged,
|
|
2761
|
+
detail: `${key} appears on ${remote}/${base} at ${merged.slice(0, 8)}`
|
|
2838
2762
|
};
|
|
2839
|
-
return this.client.records.list(this.models.issues, params);
|
|
2840
|
-
}
|
|
2841
|
-
/**
|
|
2842
|
-
* Tickets by id, for blockers that have already closed and so are absent
|
|
2843
|
-
* from the open set. Bounded to the ids actually referenced.
|
|
2844
|
-
*/
|
|
2845
|
-
async ticketsByIds(ids) {
|
|
2846
|
-
if (ids.length === 0) return [];
|
|
2847
|
-
return this.client.records.list(this.models.issues, {
|
|
2848
|
-
filters: encodeFilters([{ columnName: "id", operator: "IN", value: ids }]),
|
|
2849
|
-
limit: 500
|
|
2850
|
-
});
|
|
2851
|
-
}
|
|
2852
|
-
async ticket(id) {
|
|
2853
|
-
return this.client.records.get(this.models.issues, id);
|
|
2854
|
-
}
|
|
2855
|
-
async comments(limit = 500) {
|
|
2856
|
-
return this.client.records.list(this.models.comments, { limit });
|
|
2857
2763
|
}
|
|
2858
|
-
|
|
2859
|
-
return
|
|
2764
|
+
if (remoteBranchExists(cwd, remote, pushedBranch)) {
|
|
2765
|
+
return {
|
|
2766
|
+
state: "open",
|
|
2767
|
+
confidence: "heuristic",
|
|
2768
|
+
detail: `${remote}/${pushedBranch} still exists and ${key} is not on ${base}`
|
|
2769
|
+
};
|
|
2860
2770
|
}
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2771
|
+
return {
|
|
2772
|
+
state: "abandoned",
|
|
2773
|
+
confidence: "heuristic",
|
|
2774
|
+
detail: `${remote}/${pushedBranch} no longer exists and ${key} never reached ${base} \u2014 this is also what a squash merge with a rewritten subject looks like`
|
|
2775
|
+
};
|
|
2776
|
+
}
|
|
2777
|
+
async function detectClosure(o) {
|
|
2778
|
+
const remote = o.remote ?? "origin";
|
|
2779
|
+
const base = o.base ?? "main";
|
|
2780
|
+
const aliases = o.aliases ?? [];
|
|
2781
|
+
if (o.mergedHook) {
|
|
2782
|
+
const code = await o.mergedHook({
|
|
2783
|
+
CREW_TICKET: o.key,
|
|
2784
|
+
CREW_BRANCH: o.pushedBranch,
|
|
2785
|
+
CREW_BASE: base
|
|
2786
|
+
});
|
|
2787
|
+
if (code === 0) {
|
|
2788
|
+
const mergedAt = findKeyOnBase(o.cwd, [o.key, ...aliases], remote, base, o.since) ?? void 0;
|
|
2789
|
+
return {
|
|
2790
|
+
state: "merged",
|
|
2791
|
+
confidence: "definitive",
|
|
2792
|
+
mergedAt,
|
|
2793
|
+
detail: `the repo's merged hook says ${o.key} landed`
|
|
2794
|
+
};
|
|
2874
2795
|
}
|
|
2796
|
+
return remoteBranchExists(o.cwd, remote, o.pushedBranch) ? { state: "open", confidence: "definitive", detail: `the repo's merged hook says ${o.key} has not landed` } : { state: "abandoned", confidence: "definitive", detail: `${o.key} has not landed and ${remote}/${o.pushedBranch} is gone` };
|
|
2875
2797
|
}
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2798
|
+
return detectClosureHeuristically(o.cwd, o.key, o.pushedBranch, remote, base, o.since, aliases);
|
|
2799
|
+
}
|
|
2800
|
+
function upstreamOf(cwd, branch) {
|
|
2801
|
+
return gitOk(cwd, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", `${branch}@{upstream}`]);
|
|
2802
|
+
}
|
|
2803
|
+
function syncState(cwd, branch) {
|
|
2804
|
+
const upstream = upstreamOf(cwd, branch);
|
|
2805
|
+
if (!upstream) {
|
|
2806
|
+
return {
|
|
2807
|
+
branch,
|
|
2808
|
+
upstream: null,
|
|
2809
|
+
ahead: 0,
|
|
2810
|
+
behind: 0,
|
|
2811
|
+
dirty: status(cwd).length > 0,
|
|
2812
|
+
canFastForward: false,
|
|
2813
|
+
detail: `${branch} tracks no remote branch`
|
|
2814
|
+
};
|
|
2888
2815
|
}
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2816
|
+
const counts = gitOk(cwd, ["rev-list", "--left-right", "--count", `${upstream}...${branch}`]) ?? "0 0";
|
|
2817
|
+
const [behindStr, aheadStr] = counts.split(/\s+/);
|
|
2818
|
+
const behind = Number.parseInt(behindStr ?? "0", 10);
|
|
2819
|
+
const ahead = Number.parseInt(aheadStr ?? "0", 10);
|
|
2820
|
+
const dirty = status(cwd).length > 0;
|
|
2821
|
+
let detail;
|
|
2822
|
+
if (behind === 0 && ahead === 0) detail = `level with ${upstream}`;
|
|
2823
|
+
else if (dirty) detail = `${behind} behind ${upstream}, but the worktree is dirty \u2014 not touching it`;
|
|
2824
|
+
else if (ahead > 0 && behind > 0) detail = `diverged from ${upstream} (${ahead} ahead, ${behind} behind) \u2014 needs a human`;
|
|
2825
|
+
else if (behind > 0) detail = `${behind} behind ${upstream} \u2014 fast-forwardable`;
|
|
2826
|
+
else detail = `${ahead} ahead of ${upstream}, nothing to take`;
|
|
2827
|
+
return {
|
|
2828
|
+
branch,
|
|
2829
|
+
upstream,
|
|
2830
|
+
ahead,
|
|
2831
|
+
behind,
|
|
2832
|
+
dirty,
|
|
2833
|
+
canFastForward: behind > 0 && ahead === 0 && !dirty,
|
|
2834
|
+
detail
|
|
2835
|
+
};
|
|
2836
|
+
}
|
|
2837
|
+
function fastForward(cwd, branch) {
|
|
2838
|
+
const s = syncState(cwd, branch);
|
|
2839
|
+
if (!s.canFastForward) return false;
|
|
2840
|
+
return gitOk(cwd, ["merge", "--ff-only", s.upstream]) !== null;
|
|
2841
|
+
}
|
|
2842
|
+
function baseBranchUnsafe(r) {
|
|
2843
|
+
return r.action === "diverged" || r.action === "ahead" || r.action === "ff-failed";
|
|
2844
|
+
}
|
|
2845
|
+
function describeUnsafeBase(r, remote, base) {
|
|
2846
|
+
const counts = r.action === "diverged" ? `${r.ahead} ahead, ${r.behind} behind ${remote}/${base}` : r.action === "ahead" ? `${r.ahead} ahead of ${remote}/${base}` : `${r.behind} behind ${remote}/${base}, cannot fast-forward`;
|
|
2847
|
+
return `${r.detail} [${counts}] \u2014 reconcile it (\`crew sync\` afterwards), or \`git reset --hard ${remote}/${base}\` if the local commits duplicate a release`;
|
|
2848
|
+
}
|
|
2849
|
+
function refreshBaseBranch(cwd, remote, base, dryRun = false) {
|
|
2850
|
+
if (currentBranch(cwd) !== base || status(cwd).length > 0) {
|
|
2851
|
+
return { action: "not-applicable", detail: `${cwd} is not on a clean ${base}` };
|
|
2900
2852
|
}
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
*
|
|
2904
|
-
* Optional by design: a workspace that has not adopted ships still works,
|
|
2905
|
-
* and the crew must not require a table it did not create.
|
|
2906
|
-
*/
|
|
2907
|
-
async shipRows() {
|
|
2908
|
-
const model = this.route.resolved?.shipsModelId;
|
|
2909
|
-
if (!model) return [];
|
|
2910
|
-
try {
|
|
2911
|
-
return await this.client.records.list(model, { limit: 200 });
|
|
2912
|
-
} catch {
|
|
2913
|
-
return [];
|
|
2914
|
-
}
|
|
2853
|
+
if (!remoteConfigured(cwd, remote)) {
|
|
2854
|
+
return { action: "not-applicable", detail: `${remote} is not configured` };
|
|
2915
2855
|
}
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
* exact-string match `doctor` has always used (see the callsign comment
|
|
2919
|
-
* on ISSUE-380: name is what an operator recognises on the board).
|
|
2920
|
-
* `undefined` when the workspace has no Ships table, no row named for
|
|
2921
|
-
* this ship, or more than one — ambiguous is not a row to write to.
|
|
2922
|
-
*/
|
|
2923
|
-
async myShipRow(shipName2) {
|
|
2924
|
-
const rows = await this.shipRows();
|
|
2925
|
-
const mine = rows.filter((r) => (r.name ?? "").trim() === shipName2.trim());
|
|
2926
|
-
return mine.length === 1 ? mine[0] : void 0;
|
|
2856
|
+
if (!fetchRemote(cwd, remote)) {
|
|
2857
|
+
return { action: "fetch-failed", detail: `could not fetch ${remote}` };
|
|
2927
2858
|
}
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
* items, so every heartbeat carries the current set and a cleared item
|
|
2931
|
-
* disappears from the board on the next one. Empty when there is nothing
|
|
2932
|
-
* to write: no state directory to read, or a Ships table without the
|
|
2933
|
-
* column (probed by the key's absence from the row, the way `owner_id` is
|
|
2934
|
-
* skipped on an install that has not taken the template update).
|
|
2935
|
-
*/
|
|
2936
|
-
attentionPatch(row) {
|
|
2937
|
-
if (!this.stateDir || !("attention" in row)) return {};
|
|
2938
|
-
const items = openShipAttention(new State(this.stateDir));
|
|
2939
|
-
return { attention: items.length ? items : null };
|
|
2859
|
+
if (!remoteBranchExists(cwd, remote, base)) {
|
|
2860
|
+
return { action: "not-applicable", detail: `${remote}/${base} does not exist` };
|
|
2940
2861
|
}
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2862
|
+
const upstream = `${remote}/${base}`;
|
|
2863
|
+
const counts = gitOk(cwd, ["rev-list", "--left-right", "--count", `${upstream}...${base}`]) ?? "0 0";
|
|
2864
|
+
const [behindStr, aheadStr] = counts.split(/\s+/);
|
|
2865
|
+
const behind = Number.parseInt(behindStr ?? "0", 10);
|
|
2866
|
+
const ahead = Number.parseInt(aheadStr ?? "0", 10);
|
|
2867
|
+
const named = () => {
|
|
2868
|
+
const subjects = (gitOk(cwd, ["log", "--format=%h %s", "-5", `${upstream}..${base}`]) ?? "").split("\n").filter(Boolean);
|
|
2869
|
+
return subjects.length ? `: ${subjects.join("; ")}${ahead > subjects.length ? "; \u2026" : ""}` : "";
|
|
2870
|
+
};
|
|
2871
|
+
if (ahead > 0 && behind > 0) {
|
|
2872
|
+
return {
|
|
2873
|
+
action: "diverged",
|
|
2874
|
+
ahead,
|
|
2875
|
+
behind,
|
|
2876
|
+
detail: `${base} has diverged from ${upstream} (${ahead} ahead, ${behind} behind)${named()}`
|
|
2877
|
+
};
|
|
2954
2878
|
}
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
* of any one seat's Working/Idle status (`setCrewStatus`), which only
|
|
2958
|
-
* exists for the duration of an actual agent run. This one fires once
|
|
2959
|
-
* per `crew run`/`poll` cycle **regardless of outcome**, including a
|
|
2960
|
-
* cycle that finds nothing to do, so a ship that stops firing (powered
|
|
2961
|
-
* off, asleep, closed for the weekend) simply stops updating `last_seen`
|
|
2962
|
-
* — silence then means exactly one thing, rather than being ambiguous
|
|
2963
|
-
* with "idle".
|
|
2964
|
-
*
|
|
2965
|
-
* Does not touch the `engaged*` columns — those are set only by
|
|
2966
|
-
* `beatEngaged`/`beatIdle`, paired around the part of the cycle that
|
|
2967
|
-
* actually spawns an agent, so a plain heartbeat mid-engagement can't
|
|
2968
|
-
* blow away `engaged_since`.
|
|
2969
|
-
*
|
|
2970
|
-
* A no-op, like `shipRows()`, on a workspace with no Ships table or no
|
|
2971
|
-
* row for this ship's name.
|
|
2972
|
-
*/
|
|
2973
|
-
async beatShip(shipName2) {
|
|
2974
|
-
const row = await this.myShipRow(shipName2);
|
|
2975
|
-
if (!row) return;
|
|
2976
|
-
await this.client.records.update(this.route.resolved.shipsModelId, row.id, {
|
|
2977
|
-
last_seen: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2978
|
-
host: hostname(),
|
|
2979
|
-
pid: process.pid,
|
|
2980
|
-
...this.attentionPatch(row)
|
|
2981
|
-
});
|
|
2879
|
+
if (behind === 0 && ahead > 0) {
|
|
2880
|
+
return { action: "ahead", ahead, detail: `${base} has ${ahead} commit(s) ${upstream} does not${named()}` };
|
|
2982
2881
|
}
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
* when"). Call once, right before `spawnAgent`, paired with
|
|
2987
|
-
* `beatIdle` in a `finally` — the same shape as `setCrewStatus`'s
|
|
2988
|
-
* working/idle pair, one level up (ship, not seat).
|
|
2989
|
-
*/
|
|
2990
|
-
async beatEngaged(shipName2, connectionName, ticketRecordId) {
|
|
2991
|
-
const row = await this.myShipRow(shipName2);
|
|
2992
|
-
if (!row) return;
|
|
2993
|
-
await this.client.records.update(this.route.resolved.shipsModelId, row.id, {
|
|
2994
|
-
last_seen: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2995
|
-
host: hostname(),
|
|
2996
|
-
pid: process.pid,
|
|
2997
|
-
engaged: true,
|
|
2998
|
-
engaged_since: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2999
|
-
engaged_connection: connectionName,
|
|
3000
|
-
engaged_ticket_id: ticketRecordId,
|
|
3001
|
-
...this.attentionPatch(row)
|
|
3002
|
-
});
|
|
2882
|
+
if (behind === 0) return { action: "level", detail: `${base} is level with ${upstream}` };
|
|
2883
|
+
if (dryRun) {
|
|
2884
|
+
return { action: "would-fast-forward", behind, detail: `would fast-forward ${base}: ${behind} behind ${upstream}` };
|
|
3003
2885
|
}
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
* `tunnel_status` (disconnected/connecting/connected), as
|
|
3007
|
-
* the tunnel client (`tunnel.ts`) brings a workspace's container's tunnel
|
|
3008
|
-
* up or down. A no-op, like every other `myShipRow`-backed write here, on
|
|
3009
|
-
* a workspace with no Ships table or no row for this ship's name — the
|
|
3010
|
-
* tunnel client itself still runs the tunnel either way, it just has
|
|
3011
|
-
* nowhere on the board to report the state.
|
|
3012
|
-
*/
|
|
3013
|
-
async updateTunnelState(shipName2, patch2) {
|
|
3014
|
-
const row = await this.myShipRow(shipName2);
|
|
3015
|
-
if (!row) return;
|
|
3016
|
-
await this.client.records.update(this.route.resolved.shipsModelId, row.id, patch2);
|
|
2886
|
+
if (gitOk(cwd, ["merge", "--ff-only", upstream]) === null) {
|
|
2887
|
+
return { action: "ff-failed", behind, detail: `${base} is ${behind} behind ${upstream} and would not fast-forward` };
|
|
3017
2888
|
}
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
2889
|
+
return {
|
|
2890
|
+
action: "fast-forwarded",
|
|
2891
|
+
behind,
|
|
2892
|
+
detail: `fast-forwarded ${base} to ${upstream} (${behind} commit(s) from elsewhere)`
|
|
2893
|
+
};
|
|
2894
|
+
}
|
|
2895
|
+
function recoverStrandedReleaseCheckouts(cwd) {
|
|
2896
|
+
gitOk(cwd, ["worktree", "prune"]);
|
|
2897
|
+
const out = gitOk(cwd, ["worktree", "list", "--porcelain"]) ?? "";
|
|
2898
|
+
let path = "";
|
|
2899
|
+
for (const line of out.split("\n")) {
|
|
2900
|
+
if (line.startsWith("worktree ")) path = line.slice("worktree ".length);
|
|
2901
|
+
else if (line.startsWith("branch refs/heads/crew/release-") && path) {
|
|
2902
|
+
gitOk(cwd, ["worktree", "remove", "--force", path]);
|
|
2903
|
+
}
|
|
3032
2904
|
}
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
2905
|
+
const stranded = (gitOk(cwd, ["for-each-ref", "--format=%(refname:short)", "refs/heads/crew/release-*"]) ?? "").split("\n").filter(Boolean);
|
|
2906
|
+
for (const b of stranded) gitOk(cwd, ["branch", "-q", "-D", b]);
|
|
2907
|
+
return stranded;
|
|
2908
|
+
}
|
|
2909
|
+
function pushBranch(cwd, remote, branch, dst = branch) {
|
|
2910
|
+
git(cwd, ["push", remote, `${branch}:${dst}`]);
|
|
2911
|
+
}
|
|
2912
|
+
function worktrees(cwd) {
|
|
2913
|
+
const out = gitOk(cwd, ["worktree", "list", "--porcelain"]) ?? "";
|
|
2914
|
+
const list = [];
|
|
2915
|
+
let current = {};
|
|
2916
|
+
for (const line of out.split("\n")) {
|
|
2917
|
+
if (line.startsWith("worktree ")) {
|
|
2918
|
+
if (current.path) list.push({ path: current.path, branch: current.branch ?? null, prunable: current.prunable ?? false });
|
|
2919
|
+
current = { path: line.slice("worktree ".length) };
|
|
2920
|
+
} else if (line.startsWith("branch ")) {
|
|
2921
|
+
current.branch = line.slice("branch refs/heads/".length);
|
|
2922
|
+
} else if (line === "detached") {
|
|
2923
|
+
current.branch = null;
|
|
2924
|
+
} else if (line.startsWith("prunable ")) {
|
|
2925
|
+
current.prunable = true;
|
|
3047
2926
|
}
|
|
3048
2927
|
}
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
async epicTickets(epicIds, o) {
|
|
3062
|
-
if (epicIds.length === 0) return [];
|
|
3063
|
-
const c = this.contract;
|
|
3064
|
-
const statuses = closedStatuses(c);
|
|
3065
|
-
if (!statuses.length) return [];
|
|
3066
|
-
const filters = encodeFilters([
|
|
3067
|
-
{ columnName: c.columns.epic, operator: "IN", value: epicIds },
|
|
3068
|
-
{ columnName: c.columns.status, operator: o.closed ? "IN" : "NOT_IN", value: statuses }
|
|
3069
|
-
]);
|
|
3070
|
-
const pageSize = 500;
|
|
3071
|
-
const out = [];
|
|
3072
|
-
for (let page = 0; page < 20; page++) {
|
|
3073
|
-
const rows = await this.client.records.list(this.models.issues, {
|
|
3074
|
-
filters,
|
|
3075
|
-
limit: pageSize,
|
|
3076
|
-
offset: page * pageSize
|
|
3077
|
-
});
|
|
3078
|
-
out.push(...rows);
|
|
3079
|
-
if (rows.length < pageSize) break;
|
|
3080
|
-
}
|
|
3081
|
-
return out;
|
|
3082
|
-
}
|
|
3083
|
-
/** Conditional on `expectedUpdatedAt`, exactly as `updateTicket` is. */
|
|
3084
|
-
async updateEpic(id, patch2, expectedUpdatedAt) {
|
|
3085
|
-
const model = this.route.resolved?.epicsModelId;
|
|
3086
|
-
if (!model) throw new ConfigError(`route "${this.route.route}" has no Epics table`);
|
|
3087
|
-
return this.client.records.update(model, id, patch2, expectedUpdatedAt);
|
|
3088
|
-
}
|
|
3089
|
-
/**
|
|
3090
|
-
* `expectedUpdatedAt` makes this a conditional write (ISSUE-395, the same
|
|
3091
|
-
* `RecordsResource.update` primitive board-lock.ts uses): pass the
|
|
3092
|
-
* `updated_at` this caller last read, and the write throws
|
|
3093
|
-
* `StaleWriteError` instead of applying if the row has moved since —
|
|
3094
|
-
* exactly what claim.ts needs to let a contended ticket claim fail loudly
|
|
3095
|
-
* rather than silently overwrite a winning ship's write.
|
|
3096
|
-
*/
|
|
3097
|
-
async updateTicket(id, patch2, expectedUpdatedAt) {
|
|
3098
|
-
return this.client.records.update(this.models.issues, id, patch2, expectedUpdatedAt);
|
|
3099
|
-
}
|
|
3100
|
-
/**
|
|
3101
|
-
* Claim a board-visible lock for `scope` (ISSUE-394) — see board-lock.ts
|
|
3102
|
-
* for the CAS mechanics. Resolves to `{ ok: true }` immediately, with a
|
|
3103
|
-
* no-op release, only when this workspace has no Locks table at all. A
|
|
3104
|
-
* Locks table with no row for `scope` is `{ ok: false, reason: 'missing' }`
|
|
3105
|
-
* (CREW-1384): `crew connect` provisions the row.
|
|
3106
|
-
*/
|
|
3107
|
-
acquireBoardLock(scope, holderLabel, ttlMs) {
|
|
3108
|
-
return acquireBoardLock(this.client.records, this.route.resolved?.locksModelId, scope, holderLabel, ttlMs);
|
|
3109
|
-
}
|
|
3110
|
-
/** The Locks row for `scope` — present? held by whom? Undefined when this workspace has no Locks table. */
|
|
3111
|
-
async lockState(scope) {
|
|
3112
|
-
const model = this.route.resolved?.locksModelId;
|
|
3113
|
-
return model ? lockRowState(this.client.records, model, scope) : void 0;
|
|
3114
|
-
}
|
|
3115
|
-
/**
|
|
3116
|
-
* Reports one crew member's live status (ISSUE-325) — "Working" for the
|
|
3117
|
-
* duration of an agent run, "Idle" once it ends.
|
|
3118
|
-
*
|
|
3119
|
-
* `ticketRecordId` is the ticket's own row id (`Ticket.id`), not its
|
|
3120
|
-
* `issue_id` label — the field it fills is a Reference, not text.
|
|
3121
|
-
* Omitted (as on the idle transition) leaves the column alone, so it
|
|
3122
|
-
* keeps reading as "current/last issue" rather than clearing to blank
|
|
3123
|
-
* the moment a run ends.
|
|
3124
|
-
*/
|
|
3125
|
-
async setCrewStatus(memberId, status2, ticketRecordId) {
|
|
3126
|
-
const patch2 = { status: status2, status_updated_at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
3127
|
-
if (ticketRecordId !== void 0) patch2.current_issue_id = ticketRecordId;
|
|
3128
|
-
await this.client.records.update(this.models.crew, memberId, patch2);
|
|
2928
|
+
if (current.path) list.push({ path: current.path, branch: current.branch ?? null, prunable: current.prunable ?? false });
|
|
2929
|
+
return list;
|
|
2930
|
+
}
|
|
2931
|
+
function cloneUrlFor(remote) {
|
|
2932
|
+
return `git@github.com:${remote}.git`;
|
|
2933
|
+
}
|
|
2934
|
+
function ensureRepoCheckout(dir, remote) {
|
|
2935
|
+
if (existsSync6(`${dir}/.git`)) return false;
|
|
2936
|
+
if (!remote) {
|
|
2937
|
+
throw new GitError(
|
|
2938
|
+
`${dir} does not exist and this repo has no discovered remote to clone from \u2014 re-run \`crew connect\`, or check out the repository yourself and point \`repos:\` at it`
|
|
2939
|
+
);
|
|
3129
2940
|
}
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
await this.client.records.create(this.models.comments, {
|
|
3138
|
-
ticket_id: ticketId,
|
|
3139
|
-
body,
|
|
3140
|
-
team_member_id: memberId,
|
|
3141
|
-
kind: "event",
|
|
3142
|
-
...auth.commentShip && auth.shipId ? { [this.contract.comments.ship]: auth.shipId } : {}
|
|
3143
|
-
});
|
|
2941
|
+
mkdirSync4(dirname4(dir), { recursive: true });
|
|
2942
|
+
try {
|
|
2943
|
+
execFileSync("git", ["clone", cloneUrlFor(remote), dir], { stdio: ["ignore", "pipe", "pipe"] });
|
|
2944
|
+
} catch (e) {
|
|
2945
|
+
const err = e;
|
|
2946
|
+
const why = [err.stderr, err.stdout].map((x) => (x ?? "").trim()).filter(Boolean).join(" / ");
|
|
2947
|
+
throw new GitError(`git clone ${cloneUrlFor(remote)} ${dir}: ${why || err.message.trim()}`);
|
|
3144
2948
|
}
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
2949
|
+
return true;
|
|
2950
|
+
}
|
|
2951
|
+
var CHECKOUT_LOCK_STALE_MS = 5 * 60 * 1e3;
|
|
2952
|
+
function checkoutLockPath(dir) {
|
|
2953
|
+
return `${dir}.sync.lock`;
|
|
2954
|
+
}
|
|
2955
|
+
function sleepSync(ms) {
|
|
2956
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
2957
|
+
}
|
|
2958
|
+
function acquireCheckoutLock(dir, timeoutMs = 3e4) {
|
|
2959
|
+
const lockPath = checkoutLockPath(dir);
|
|
2960
|
+
mkdirSync4(dirname4(lockPath), { recursive: true });
|
|
2961
|
+
const deadline = Date.now() + timeoutMs;
|
|
2962
|
+
for (; ; ) {
|
|
2963
|
+
try {
|
|
2964
|
+
const fd = openSync(lockPath, "wx");
|
|
2965
|
+
writeSync(fd, `${process.pid}
|
|
2966
|
+
`);
|
|
2967
|
+
closeSync(fd);
|
|
2968
|
+
return () => {
|
|
2969
|
+
try {
|
|
2970
|
+
unlinkSync2(lockPath);
|
|
2971
|
+
} catch {
|
|
2972
|
+
}
|
|
2973
|
+
};
|
|
2974
|
+
} catch (e) {
|
|
2975
|
+
const err = e;
|
|
2976
|
+
if (err.code !== "EEXIST") throw err;
|
|
2977
|
+
try {
|
|
2978
|
+
if (Date.now() - statSync3(lockPath).mtimeMs > CHECKOUT_LOCK_STALE_MS) {
|
|
2979
|
+
try {
|
|
2980
|
+
unlinkSync2(lockPath);
|
|
2981
|
+
} catch {
|
|
2982
|
+
}
|
|
2983
|
+
continue;
|
|
2984
|
+
}
|
|
2985
|
+
} catch {
|
|
2986
|
+
}
|
|
2987
|
+
if (Date.now() > deadline) {
|
|
2988
|
+
throw new GitError(`timed out waiting for the sync lock on ${dir} (held by another sync)`);
|
|
2989
|
+
}
|
|
2990
|
+
sleepSync(50);
|
|
2991
|
+
}
|
|
3158
2992
|
}
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
2993
|
+
}
|
|
2994
|
+
function syncPassengerCheckout(dir, githubRemote, gitRemoteName, base) {
|
|
2995
|
+
const release = acquireCheckoutLock(dir);
|
|
2996
|
+
try {
|
|
2997
|
+
if (ensureRepoCheckout(dir, githubRemote)) {
|
|
2998
|
+
if (currentBranch(dir) !== base) git(dir, ["checkout", base]);
|
|
2999
|
+
return { action: "cloned", detail: `cloned ${githubRemote} into ${dir}` };
|
|
3000
|
+
}
|
|
3001
|
+
return refreshBaseBranch(dir, gitRemoteName, base);
|
|
3002
|
+
} finally {
|
|
3003
|
+
release();
|
|
3169
3004
|
}
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3005
|
+
}
|
|
3006
|
+
|
|
3007
|
+
// src/ship-attention.ts
|
|
3008
|
+
var RELEASE_STALE_AFTER_INTERVALS = 3;
|
|
3009
|
+
function upsert(items, item) {
|
|
3010
|
+
const existing = items.find((i) => i.key === item.key);
|
|
3011
|
+
if (!existing) return [...items, item];
|
|
3012
|
+
return items.map((i) => i.key === item.key ? { ...item, since: existing.since } : i);
|
|
3013
|
+
}
|
|
3014
|
+
function clear(items, key) {
|
|
3015
|
+
return items.filter((i) => i.key !== key);
|
|
3016
|
+
}
|
|
3017
|
+
function transitions(previous, current) {
|
|
3018
|
+
const before = new Set(previous.map((i) => i.key));
|
|
3019
|
+
return current.filter((i) => !before.has(i.key));
|
|
3020
|
+
}
|
|
3021
|
+
function staleRelease(lastRunMs, nowMs, intervalSeconds = INTERVAL_SECONDS) {
|
|
3022
|
+
if (lastRunMs === null) return null;
|
|
3023
|
+
const limitMs = RELEASE_STALE_AFTER_INTERVALS * intervalSeconds * 1e3;
|
|
3024
|
+
const ageMs = nowMs - lastRunMs;
|
|
3025
|
+
if (ageMs <= limitMs) return null;
|
|
3026
|
+
const minutes = Math.round(ageMs / 6e4);
|
|
3027
|
+
return {
|
|
3028
|
+
kind: "release_stale",
|
|
3029
|
+
key: "release_stale",
|
|
3030
|
+
message: `This ship has not run a release cycle for ${minutes} minute${minutes === 1 ? "" : "s"} \u2014 check its release timer`,
|
|
3031
|
+
since: new Date(lastRunMs + limitMs).toISOString()
|
|
3032
|
+
};
|
|
3033
|
+
}
|
|
3034
|
+
var baseUnsafeKey = (repo) => `base_unsafe:${repo}`;
|
|
3035
|
+
function baseAttention(repo, outcome, remote, base, nowIso = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
3036
|
+
if (baseBranchUnsafe(outcome)) {
|
|
3177
3037
|
return {
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3038
|
+
raise: {
|
|
3039
|
+
kind: "base_unsafe",
|
|
3040
|
+
key: baseUnsafeKey(repo),
|
|
3041
|
+
since: nowIso,
|
|
3042
|
+
message: `${repo}: no worktree can be cut until the primary checkout's ${base} is reconciled \u2014 ${describeUnsafeBase(outcome, remote, base)}. Resumes automatically once it is level with ${remote}/${base}`
|
|
3043
|
+
}
|
|
3183
3044
|
};
|
|
3184
3045
|
}
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3046
|
+
if (outcome.action === "level" || outcome.action === "fast-forwarded") return { clear: baseUnsafeKey(repo) };
|
|
3047
|
+
return null;
|
|
3048
|
+
}
|
|
3049
|
+
function openShipAttention(state, nowMs = Date.now()) {
|
|
3050
|
+
const stale = staleRelease(state.releaseHeartbeat().at(), nowMs);
|
|
3051
|
+
const stored = state.shipAttention().previous();
|
|
3052
|
+
return stale ? [...stored, stale] : stored;
|
|
3053
|
+
}
|
|
3054
|
+
function announce(emit, item) {
|
|
3055
|
+
emit.warn(item.message, {
|
|
3056
|
+
step: "sweep",
|
|
3057
|
+
data: { attention: [`ship:${item.kind}`], title: item.message }
|
|
3058
|
+
});
|
|
3059
|
+
}
|
|
3060
|
+
async function raiseShipAttention(o, item) {
|
|
3061
|
+
const store = o.state.shipAttention();
|
|
3062
|
+
const previous = store.previous();
|
|
3063
|
+
const next = upsert(previous, item);
|
|
3064
|
+
store.persist(next);
|
|
3065
|
+
if (transitions(previous, next).length === 0) return false;
|
|
3066
|
+
announce(o.emit, item);
|
|
3067
|
+
if (o.route && o.ship) {
|
|
3068
|
+
await notify(o.route, o.ship, { level: "warn", headline: item.message }, o.emit);
|
|
3192
3069
|
}
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
* un-retires a retired one — right for a ticket that just went back to
|
|
3211
|
-
* `needs_info` after having been resolved once before.
|
|
3212
|
-
*
|
|
3213
|
-
* `undefined` when this route has no `recordLinkViewId` resolved, or the
|
|
3214
|
-
* mint call fails for any reason (network, 404, an unexpected response
|
|
3215
|
-
* shape) — the caller (poll.ts's attention sweep) still emits its
|
|
3216
|
-
* notification event without a link rather than losing the whole event.
|
|
3217
|
-
*/
|
|
3218
|
-
async recordLinkUrl(recordId) {
|
|
3219
|
-
const viewId = this.route.resolved?.recordLinkViewId;
|
|
3220
|
-
if (!viewId) return void 0;
|
|
3221
|
-
try {
|
|
3222
|
-
const res = await fetch(`${this.route.baseUrl}/api/views/${viewId}/record-links`, {
|
|
3223
|
-
method: "POST",
|
|
3224
|
-
headers: {
|
|
3225
|
-
Authorization: `Bearer ${resolveApiKey(this.route)}`,
|
|
3226
|
-
"User-Agent": this.userAgent,
|
|
3227
|
-
"Content-Type": "application/json"
|
|
3228
|
-
},
|
|
3229
|
-
body: JSON.stringify({ dataModelId: this.models.issues, recordId })
|
|
3230
|
-
});
|
|
3231
|
-
if (!res.ok) return void 0;
|
|
3232
|
-
const body = await res.json();
|
|
3233
|
-
return typeof body.url === "string" ? body.url : void 0;
|
|
3234
|
-
} catch {
|
|
3235
|
-
return void 0;
|
|
3236
|
-
}
|
|
3237
|
-
}
|
|
3238
|
-
/**
|
|
3239
|
-
* File a brand new ticket. Unlike every other write on this class, the
|
|
3240
|
-
* field names are the fixed Bug Reports schema (`title`, `description`,
|
|
3241
|
-
* `report_type`, `severity`, `reporter_name`) rather than anything in
|
|
3242
|
-
* `Contract.columns` — that contract only names the lifecycle fields a
|
|
3243
|
-
* workspace can rename, and creation-only fields like `description` are
|
|
3244
|
-
* not among them (`postEvent` above hardcodes its own fields the same
|
|
3245
|
-
* way).
|
|
3246
|
-
*/
|
|
3247
|
-
async fileTicket(fields, memberId) {
|
|
3248
|
-
const auth = await this.authorshipProbe();
|
|
3249
|
-
return this.client.records.create(this.models.issues, {
|
|
3250
|
-
...fields,
|
|
3251
|
-
...auth.issueAuthor && memberId ? { [this.contract.columns.author]: memberId } : {},
|
|
3252
|
-
...auth.issueShip && auth.shipId ? { [this.contract.columns.ship]: auth.shipId } : {}
|
|
3253
|
-
});
|
|
3070
|
+
return true;
|
|
3071
|
+
}
|
|
3072
|
+
function clearShipAttention(state, key) {
|
|
3073
|
+
const store = state.shipAttention();
|
|
3074
|
+
const previous = store.previous();
|
|
3075
|
+
const next = clear(previous, key);
|
|
3076
|
+
if (next.length !== previous.length) store.persist(next);
|
|
3077
|
+
}
|
|
3078
|
+
function hookCommand(script) {
|
|
3079
|
+
for (const raw of script.split("\n")) {
|
|
3080
|
+
const line = raw.trim();
|
|
3081
|
+
if (!line || line.startsWith("#")) continue;
|
|
3082
|
+
const word = line.split(/\s+/)[0];
|
|
3083
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) continue;
|
|
3084
|
+
if (!/^[A-Za-z0-9._+-]+$/.test(word)) return null;
|
|
3085
|
+
if (SHELL_WORDS.has(word)) return null;
|
|
3086
|
+
return word;
|
|
3254
3087
|
}
|
|
3255
|
-
|
|
3088
|
+
return null;
|
|
3089
|
+
}
|
|
3090
|
+
var SHELL_WORDS = /* @__PURE__ */ new Set([
|
|
3091
|
+
"if",
|
|
3092
|
+
"then",
|
|
3093
|
+
"else",
|
|
3094
|
+
"elif",
|
|
3095
|
+
"fi",
|
|
3096
|
+
"for",
|
|
3097
|
+
"while",
|
|
3098
|
+
"until",
|
|
3099
|
+
"do",
|
|
3100
|
+
"done",
|
|
3101
|
+
"case",
|
|
3102
|
+
"esac",
|
|
3103
|
+
"cd",
|
|
3104
|
+
"echo",
|
|
3105
|
+
"export",
|
|
3106
|
+
"set",
|
|
3107
|
+
"exit",
|
|
3108
|
+
"test",
|
|
3109
|
+
"true",
|
|
3110
|
+
"false",
|
|
3111
|
+
"source",
|
|
3112
|
+
".",
|
|
3113
|
+
"eval",
|
|
3114
|
+
"exec",
|
|
3115
|
+
"ok"
|
|
3116
|
+
]);
|
|
3256
3117
|
|
|
3257
|
-
// src/
|
|
3258
|
-
import { StaleWriteError
|
|
3259
|
-
function
|
|
3260
|
-
|
|
3261
|
-
const missing = /* @__PURE__ */ new Set();
|
|
3262
|
-
for (const t of tickets) for (const id of t.blocked_by ?? []) if (!known.has(id)) missing.add(id);
|
|
3263
|
-
return [...missing];
|
|
3118
|
+
// src/board-lock.ts
|
|
3119
|
+
import { StaleWriteError } from "@tablation/client";
|
|
3120
|
+
function releaseLockScope(route, repo) {
|
|
3121
|
+
return `${route}/${repo}`;
|
|
3264
3122
|
}
|
|
3265
|
-
function
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3123
|
+
async function acquireBoardLock(client2, modelId, scope, holderLabel, ttlMs) {
|
|
3124
|
+
if (!modelId) return { ok: true, release: async () => {
|
|
3125
|
+
} };
|
|
3126
|
+
const filters = JSON.stringify([{ columnName: "scope", operator: "EQ", value: scope }]);
|
|
3127
|
+
const rows = await client2.list(modelId, { filters, limit: 1 });
|
|
3128
|
+
const row = rows[0];
|
|
3129
|
+
if (!row) return { ok: false, reason: "missing" };
|
|
3130
|
+
const age = Date.now() - new Date(row.updated_at).getTime();
|
|
3131
|
+
const free = !row.holder || row.holder === holderLabel || age > ttlMs;
|
|
3132
|
+
if (!free) return { ok: false, heldBy: row.holder ?? void 0, reason: "held" };
|
|
3133
|
+
let claimed;
|
|
3134
|
+
try {
|
|
3135
|
+
claimed = await client2.update(modelId, row.id, { holder: holderLabel }, row.updated_at);
|
|
3136
|
+
} catch (e) {
|
|
3137
|
+
if (e instanceof StaleWriteError) return { ok: false, reason: "contended" };
|
|
3138
|
+
throw e;
|
|
3269
3139
|
}
|
|
3270
|
-
return
|
|
3140
|
+
return {
|
|
3141
|
+
ok: true,
|
|
3142
|
+
release: async () => {
|
|
3143
|
+
try {
|
|
3144
|
+
await client2.update(modelId, row.id, { holder: "" }, claimed.updated_at);
|
|
3145
|
+
} catch {
|
|
3146
|
+
}
|
|
3147
|
+
}
|
|
3148
|
+
};
|
|
3271
3149
|
}
|
|
3272
|
-
function
|
|
3273
|
-
const
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
return status2 !== void 0 && !RESOLVED_STATUSES.has(status2);
|
|
3278
|
-
});
|
|
3279
|
-
if (unresolved) blocked.add(t.id);
|
|
3280
|
-
}
|
|
3281
|
-
return blocked;
|
|
3150
|
+
async function lockRowState(client2, modelId, scope) {
|
|
3151
|
+
const rows = await client2.list(modelId, { filters: scopeFilter(scope), limit: 1 });
|
|
3152
|
+
const row = rows[0];
|
|
3153
|
+
if (!row) return { exists: false };
|
|
3154
|
+
return { exists: true, ...row.holder ? { holder: row.holder, since: row.updated_at } : {} };
|
|
3282
3155
|
}
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
const
|
|
3286
|
-
for (const
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3156
|
+
var scopeFilter = (scope) => JSON.stringify([{ columnName: "scope", operator: "EQ", value: scope }]);
|
|
3157
|
+
async function ensureLockRows(client2, modelId, scopes) {
|
|
3158
|
+
const created = [];
|
|
3159
|
+
for (const scope of scopes) {
|
|
3160
|
+
const filters = scopeFilter(scope);
|
|
3161
|
+
if ((await client2.list(modelId, { filters, limit: 1 })).length > 0) continue;
|
|
3162
|
+
let mine;
|
|
3163
|
+
try {
|
|
3164
|
+
mine = await client2.create(modelId, { scope, holder: "" });
|
|
3165
|
+
} catch (e) {
|
|
3166
|
+
const status2 = e.status;
|
|
3167
|
+
if (status2 === 409 || status2 === 400) {
|
|
3168
|
+
if ((await client2.list(modelId, { filters, limit: 1 })).length > 0) continue;
|
|
3169
|
+
}
|
|
3170
|
+
throw e;
|
|
3290
3171
|
}
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
for (const t of tickets) {
|
|
3298
|
-
if (t.status === "accepted" && blocked.has(t.id)) {
|
|
3299
|
-
steps.push({ action: "park", ticket: t, blockers: describeBlockers(t, info), to: "blocked" });
|
|
3300
|
-
} else if (t.status === "blocked" && !blocked.has(t.id)) {
|
|
3301
|
-
steps.push({ action: "restore", ticket: t, blockers: describeBlockers(t, info), to: "accepted" });
|
|
3172
|
+
const all = await client2.list(modelId, { filters, limit: 20 });
|
|
3173
|
+
const keeper = [...all].sort((a, b) => (a.created_at ?? "").localeCompare(b.created_at ?? "") || a.id.localeCompare(b.id))[0];
|
|
3174
|
+
if (keeper && keeper.id !== mine.id) {
|
|
3175
|
+
await client2.remove(modelId, mine.id).catch(() => {
|
|
3176
|
+
});
|
|
3177
|
+
continue;
|
|
3302
3178
|
}
|
|
3179
|
+
created.push(scope);
|
|
3303
3180
|
}
|
|
3304
|
-
return
|
|
3181
|
+
return created;
|
|
3305
3182
|
}
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3183
|
+
|
|
3184
|
+
// src/tracker.ts
|
|
3185
|
+
function ticketBranchContext(t) {
|
|
3186
|
+
const tag = t.issue_tag ?? void 0;
|
|
3187
|
+
if (!tag) return {};
|
|
3188
|
+
const prefix = t.project_issue_prefix ? t.project_issue_prefix.toLowerCase() : void 0;
|
|
3189
|
+
return { tag, prefix };
|
|
3310
3190
|
}
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
coordinatingValue: "coordinating"
|
|
3314
|
-
};
|
|
3315
|
-
var isCoordinating = (t, opts) => t[opts.reportTypeColumn] === opts.coordinatingValue;
|
|
3316
|
-
var FILING_ERROR_DEFAULT_EXEMPT = ["coordinating", "question", "investigation"];
|
|
3317
|
-
function rollUpParents(tickets, done = RESOLVED_STATUSES, opts = {}) {
|
|
3318
|
-
const resolved = { ...COORDINATING_DEFAULTS, ...opts };
|
|
3319
|
-
const byParent = /* @__PURE__ */ new Map();
|
|
3320
|
-
for (const t of tickets) {
|
|
3321
|
-
const p = t.parent_id;
|
|
3322
|
-
if (!p) continue;
|
|
3323
|
-
const list = byParent.get(p);
|
|
3324
|
-
if (list) list.push(t);
|
|
3325
|
-
else byParent.set(p, [t]);
|
|
3326
|
-
}
|
|
3327
|
-
const rollups = [];
|
|
3328
|
-
for (const t of tickets) {
|
|
3329
|
-
const children = byParent.get(t.id);
|
|
3330
|
-
if (!isCoordinating(t, resolved) || !children) continue;
|
|
3331
|
-
const outstanding = children.filter((c) => !done.has(c.status));
|
|
3332
|
-
rollups.push({ parent: t, children, outstanding, complete: outstanding.length === 0 });
|
|
3333
|
-
}
|
|
3334
|
-
return rollups;
|
|
3191
|
+
function displayKey(t) {
|
|
3192
|
+
return t.issue_tag ?? t.issue_id;
|
|
3335
3193
|
}
|
|
3336
|
-
function
|
|
3337
|
-
|
|
3338
|
-
const coordinatingValue = opts.coordinatingValue ?? COORDINATING_DEFAULTS.coordinatingValue;
|
|
3339
|
-
const exempt = new Set(
|
|
3340
|
-
opts.repoExemptValues ?? [coordinatingValue, ...FILING_ERROR_DEFAULT_EXEMPT.filter((v) => v !== "coordinating")]
|
|
3341
|
-
);
|
|
3342
|
-
return tickets.filter((t) => {
|
|
3343
|
-
if (t.repo_id) return false;
|
|
3344
|
-
return !exempt.has(t[reportTypeColumn]);
|
|
3345
|
-
});
|
|
3194
|
+
function referenceKeys(t) {
|
|
3195
|
+
return t.issue_tag && t.issue_tag !== t.issue_id ? [t.issue_id, t.issue_tag] : [t.issue_id];
|
|
3346
3196
|
}
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3197
|
+
var RESOLVED_STATUSES = new Set(DEFAULT_CONTRACT.statuses.resolved);
|
|
3198
|
+
var encodeFilters = (f) => JSON.stringify(f);
|
|
3199
|
+
var encodeSort = (s) => JSON.stringify(s);
|
|
3200
|
+
var Tracker = class {
|
|
3201
|
+
// NB: explicit fields, not TypeScript parameter properties — node's
|
|
3202
|
+
// --experimental-strip-types cannot transform those, and the test runner
|
|
3203
|
+
// uses it. The same rule applies everywhere in src/.
|
|
3204
|
+
route;
|
|
3205
|
+
/** This workspace's rules. A ship holds several, one per route. */
|
|
3206
|
+
contract;
|
|
3207
|
+
client;
|
|
3208
|
+
models;
|
|
3209
|
+
userAgent;
|
|
3210
|
+
shipName;
|
|
3211
|
+
/**
|
|
3212
|
+
* The once-per-process authorship probe (CREW-1371), memoised as a promise
|
|
3213
|
+
* so concurrent first writes share one round of field-metadata reads.
|
|
3214
|
+
*/
|
|
3215
|
+
authorship;
|
|
3216
|
+
stateDir;
|
|
3217
|
+
constructor(route, ship) {
|
|
3218
|
+
this.route = route;
|
|
3219
|
+
if (!route.resolved) {
|
|
3220
|
+
throw new ConfigError(
|
|
3221
|
+
`route "${route.route}" has no resolved ids \u2014 run \`crew connect\` to discover them from the project`
|
|
3367
3222
|
);
|
|
3368
|
-
try {
|
|
3369
|
-
await writer.postEvent(step.ticket.id, sweepComment(step), memberId);
|
|
3370
|
-
} catch {
|
|
3371
|
-
log.warn("status changed but the note failed to post", { ticket: step.ticket.issue_id, step: "sweep" });
|
|
3372
|
-
}
|
|
3373
|
-
} catch (e) {
|
|
3374
|
-
if (e instanceof StaleWriteError2) {
|
|
3375
|
-
contended++;
|
|
3376
|
-
log.warn(`skipped ${step.action} \u2014 ticket changed since the sweep was planned`, {
|
|
3377
|
-
ticket: step.ticket.issue_id,
|
|
3378
|
-
step: "sweep"
|
|
3379
|
-
});
|
|
3380
|
-
continue;
|
|
3381
|
-
}
|
|
3382
|
-
failed++;
|
|
3383
|
-
log.warn(`could not ${step.action}: ${e.message}`, { ticket: step.ticket.issue_id, step: "sweep" });
|
|
3384
3223
|
}
|
|
3224
|
+
this.models = route.resolved.models;
|
|
3225
|
+
this.contract = resolveContract(route.contract);
|
|
3226
|
+
this.userAgent = ship.userAgent;
|
|
3227
|
+
this.shipName = ship.name;
|
|
3228
|
+
this.stateDir = ship.stateDir;
|
|
3229
|
+
this.client = new TablationClient({
|
|
3230
|
+
baseUrl: `${route.baseUrl}/api`,
|
|
3231
|
+
apiKey: resolveApiKey(route),
|
|
3232
|
+
// Cloudflare 403s (error 1010) default agents on this host.
|
|
3233
|
+
headers: { "User-Agent": ship.userAgent }
|
|
3234
|
+
});
|
|
3385
3235
|
}
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
}
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
const id = t.issue_id ?? "ISSUE-0";
|
|
3407
|
-
const n = Number.parseInt(id.split("-")[1] ?? "0", 10);
|
|
3408
|
-
return Number.isNaN(n) ? 0 : n;
|
|
3409
|
-
}
|
|
3410
|
-
function epicRank(t) {
|
|
3411
|
-
return t.epicInProgress ? 0 : 1;
|
|
3412
|
-
}
|
|
3413
|
-
function rank(t, c = DEFAULT_CONTRACT) {
|
|
3414
|
-
return [effectivePriority(t, c), severityRank(t, c), epicRank(t), issueNumber(t)];
|
|
3415
|
-
}
|
|
3416
|
-
function compareRank(a, b, c = DEFAULT_CONTRACT) {
|
|
3417
|
-
const ra = rank(a, c);
|
|
3418
|
-
const rb = rank(b, c);
|
|
3419
|
-
for (let i = 0; i < ra.length; i++) {
|
|
3420
|
-
const d = ra[i] - rb[i];
|
|
3421
|
-
if (d !== 0) return d;
|
|
3236
|
+
/**
|
|
3237
|
+
* Every non-terminal ticket for this route's area, in one call.
|
|
3238
|
+
*
|
|
3239
|
+
* The area filter is what keeps two routes on one tracker out of each
|
|
3240
|
+
* other's queue: a ship working `synthesis` and `tablation-js` from the same
|
|
3241
|
+
* Issues project must not have either seat pick up the other's tickets. A
|
|
3242
|
+
* route with no area sees everything, which is right for a tracker
|
|
3243
|
+
* nobody has sliced yet.
|
|
3244
|
+
*/
|
|
3245
|
+
async openTickets() {
|
|
3246
|
+
const c = this.contract;
|
|
3247
|
+
const filters = [
|
|
3248
|
+
{ columnName: c.columns.status, operator: "IN", value: c.statuses.open }
|
|
3249
|
+
];
|
|
3250
|
+
const areaId = this.route.resolved?.areaId;
|
|
3251
|
+
if (areaId) filters.push({ columnName: c.columns.slice, operator: "EQ", value: areaId });
|
|
3252
|
+
return this.client.records.list(this.models.issues, {
|
|
3253
|
+
filters: encodeFilters(filters),
|
|
3254
|
+
limit: 500
|
|
3255
|
+
});
|
|
3422
3256
|
}
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
|
|
3435
|
-
|
|
3436
|
-
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
}
|
|
3456
|
-
function heldByLiveOtherShip(t, a) {
|
|
3457
|
-
if (!a.myShipId) return false;
|
|
3458
|
-
const holder = ticketHolderShipId(t, a.contract);
|
|
3459
|
-
if (!holder || holder === a.myShipId) return false;
|
|
3460
|
-
const owner = a.ships.find((s) => s.id === holder);
|
|
3461
|
-
return shipIsAlive(owner, a.now ?? Date.now());
|
|
3462
|
-
}
|
|
3463
|
-
function shipName(ships, id) {
|
|
3464
|
-
if (!id) return "an unknown ship";
|
|
3465
|
-
return ships.find((s) => s.id === id)?.name?.trim() || id;
|
|
3466
|
-
}
|
|
3467
|
-
function shipIdByName(ships, name) {
|
|
3468
|
-
const mine = ships.filter((r) => (r.name ?? "").trim() === name.trim());
|
|
3469
|
-
return mine.length === 1 ? mine[0].id : null;
|
|
3470
|
-
}
|
|
3471
|
-
async function noteReaccepted(tracker, result, seat, shipLabel) {
|
|
3472
|
-
if (!result.reaccepted || !result.ticket) return;
|
|
3473
|
-
try {
|
|
3474
|
-
await tracker.postEvent(
|
|
3475
|
-
result.ticket.id,
|
|
3476
|
-
`Re-accepted while held by ${shipLabel}; resuming there, not re-claiming. Setting a held ticket back to \`accepted\` is not needed \u2014 to move it to another ship, clear \`held_by_ship_id\`.`,
|
|
3477
|
-
seat
|
|
3478
|
-
);
|
|
3479
|
-
} catch {
|
|
3257
|
+
/**
|
|
3258
|
+
* Tickets at a terminal, no-longer-open status — the worktree sweep's
|
|
3259
|
+
* input (ISSUE-346).
|
|
3260
|
+
*
|
|
3261
|
+
* The exact complement of `openTickets()`: `closed_deployed`,
|
|
3262
|
+
* `closed_wont_fix`, `closed_duplicate`. `verified` is excluded even
|
|
3263
|
+
* though the contract counts it as resolved for a *blocker*'s purposes —
|
|
3264
|
+
* it is still pre-release, and its worktree is exactly what the release
|
|
3265
|
+
* phase is about to merge.
|
|
3266
|
+
*
|
|
3267
|
+
* Sorted most-recently-updated first: a board can hold far more than
|
|
3268
|
+
* `limit` closed tickets (this one already has), and an unordered fetch
|
|
3269
|
+
* has no guarantee which ones a truncated page contains. Ordering by
|
|
3270
|
+
* `updatedAt` descending means a truncated page still holds the tickets
|
|
3271
|
+
* most likely to need their worktree swept — the ones that closed most
|
|
3272
|
+
* recently — rather than an arbitrary, possibly stale slice (ISSUE-403).
|
|
3273
|
+
*/
|
|
3274
|
+
async terminalTickets() {
|
|
3275
|
+
const c = this.contract;
|
|
3276
|
+
const statuses = closedStatuses(c);
|
|
3277
|
+
if (!statuses.length) return [];
|
|
3278
|
+
const filters = [
|
|
3279
|
+
{ columnName: c.columns.status, operator: "IN", value: statuses }
|
|
3280
|
+
];
|
|
3281
|
+
const areaId = this.route.resolved?.areaId;
|
|
3282
|
+
if (areaId) filters.push({ columnName: c.columns.slice, operator: "EQ", value: areaId });
|
|
3283
|
+
const params = {
|
|
3284
|
+
filters: encodeFilters(filters),
|
|
3285
|
+
limit: 500,
|
|
3286
|
+
sort: encodeSort([{ columnName: c.columns.updatedAt, direction: "desc" }])
|
|
3287
|
+
};
|
|
3288
|
+
return this.client.records.list(this.models.issues, params);
|
|
3480
3289
|
}
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
cleared++;
|
|
3492
|
-
emit.emit(`released ship hold (${t.status})`, { ticket: t.issue_id, step: "sweep" });
|
|
3493
|
-
} catch (e) {
|
|
3494
|
-
emit.warn(`could not release ship hold: ${e.message}`, { ticket: t.issue_id, step: "sweep" });
|
|
3495
|
-
}
|
|
3290
|
+
/**
|
|
3291
|
+
* Tickets by id, for blockers that have already closed and so are absent
|
|
3292
|
+
* from the open set. Bounded to the ids actually referenced.
|
|
3293
|
+
*/
|
|
3294
|
+
async ticketsByIds(ids) {
|
|
3295
|
+
if (ids.length === 0) return [];
|
|
3296
|
+
return this.client.records.list(this.models.issues, {
|
|
3297
|
+
filters: encodeFilters([{ columnName: "id", operator: "IN", value: ids }]),
|
|
3298
|
+
limit: 500
|
|
3299
|
+
});
|
|
3496
3300
|
}
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
async function noteTakeover(tracker, result, seat, ships, myShipName, continuingFrom) {
|
|
3500
|
-
if (!result.tookOverFrom || !result.ticket) return;
|
|
3501
|
-
let sha = null;
|
|
3502
|
-
try {
|
|
3503
|
-
sha = continuingFrom(result.ticket);
|
|
3504
|
-
} catch {
|
|
3301
|
+
async ticket(id) {
|
|
3302
|
+
return this.client.records.get(this.models.issues, id);
|
|
3505
3303
|
}
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
result.ticket.id,
|
|
3509
|
-
`Taken over from ${shipName(ships, result.tookOverFrom)} (not seen recently) by ${myShipName}; ` + (sha ? `continuing from \`${sha}\` on origin.` : "no branch commit was found on origin to continue from."),
|
|
3510
|
-
seat
|
|
3511
|
-
);
|
|
3512
|
-
} catch {
|
|
3304
|
+
async comments(limit = 500) {
|
|
3305
|
+
return this.client.records.list(this.models.comments, { limit });
|
|
3513
3306
|
}
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
const num = (t.issue_tag ?? t.issue_id).replace(/^\D+/, "");
|
|
3517
|
-
const names = [...new Set([(t.issue_tag ?? "").toLowerCase(), `issue-${num}`].filter(Boolean))];
|
|
3518
|
-
for (const n of names) {
|
|
3519
|
-
const sha = rev(dir, `origin/${n}`);
|
|
3520
|
-
if (sha) return sha.slice(0, 7);
|
|
3307
|
+
async crewRows() {
|
|
3308
|
+
return this.client.records.list(this.models.crew, { limit: 200 });
|
|
3521
3309
|
}
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
}
|
|
3534
|
-
|
|
3310
|
+
/**
|
|
3311
|
+
* One row of the `Repos` table, for whatever it can tell the crew that
|
|
3312
|
+
* isn't in `.crew.yaml` — which `Projects` (area) row it belongs to, in
|
|
3313
|
+
* particular. `undefined` on a workspace with no Repos table, or if the
|
|
3314
|
+
* row is gone; neither is fatal to the caller.
|
|
3315
|
+
*/
|
|
3316
|
+
async repoRow(id) {
|
|
3317
|
+
const model = this.route.resolved?.reposModelId;
|
|
3318
|
+
if (!model) return void 0;
|
|
3319
|
+
try {
|
|
3320
|
+
return await this.client.records.get(model, id);
|
|
3321
|
+
} catch {
|
|
3322
|
+
return void 0;
|
|
3535
3323
|
}
|
|
3536
3324
|
}
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
}
|
|
3550
|
-
var isHeld = (t, holds2) => !!t.assignee_id && holds2.has(t.assignee_id);
|
|
3551
|
-
function affinityContext(i) {
|
|
3552
|
-
return i.ships ? { myShipId: i.ships.myShipId, ships: i.ships.rows, contract: i.contract ?? DEFAULT_CONTRACT, now: i.ships.now } : null;
|
|
3553
|
-
}
|
|
3554
|
-
function withHoldCheck(affinity, i) {
|
|
3555
|
-
if (!affinity) return void 0;
|
|
3556
|
-
const a = affinityContext(i);
|
|
3557
|
-
return a ? { ...affinity, isHeldElsewhere: (t) => heldByOtherShip(t, a), isLiveHoldElsewhere: (t) => heldByLiveOtherShip(t, a) } : affinity;
|
|
3558
|
-
}
|
|
3559
|
-
function notHeldByOtherShip(slice, i) {
|
|
3560
|
-
if (!i.ships) return slice;
|
|
3561
|
-
const a = { myShipId: i.ships.myShipId, ships: i.ships.rows, contract: i.contract ?? DEFAULT_CONTRACT, now: i.ships.now };
|
|
3562
|
-
return slice.filter((t) => !heldByOtherShip(t, a));
|
|
3563
|
-
}
|
|
3564
|
-
function buildingRoleHasWork(role, i) {
|
|
3565
|
-
const me = i.seats[role];
|
|
3566
|
-
if (!me) return { hasWork: false, reason: "this ship does not crew that role" };
|
|
3567
|
-
const mine = notHeldByOtherShip(sliceFor(i.tickets, role), i);
|
|
3568
|
-
const startable = mine.filter(
|
|
3569
|
-
(t) => (t.status === "accepted" || t.status === "blocked") && !i.blocked.has(t.id) && !isHeld(t, i.holds)
|
|
3570
|
-
);
|
|
3571
|
-
const watched = mine.filter(
|
|
3572
|
-
(t) => (t.status === "in_progress" && (t.assignee_id === me || !t.assignee_id) || t.status === "needs_info") && !isHeld(t, i.holds)
|
|
3573
|
-
);
|
|
3574
|
-
const watchedIds = new Set(watched.map((t) => t.id));
|
|
3575
|
-
const newComments = i.comments.filter(
|
|
3576
|
-
(c) => c.created_at > i.watermark && c.team_member_id !== me && (c.kind ?? "comment") !== "event" && watchedIds.has(c.ticket_id)
|
|
3577
|
-
).length;
|
|
3578
|
-
const unassigned = mine.filter(
|
|
3579
|
-
(t) => t.status === "in_progress" && !t.assignee_id && !i.blocked.has(t.id)
|
|
3580
|
-
).length;
|
|
3581
|
-
const resumable = mine.filter(
|
|
3582
|
-
(t) => t.status === "in_progress" && t.assignee_id === me && !t.needs_planning && !t.needs_review && !isHeld(t, i.holds)
|
|
3583
|
-
).length;
|
|
3584
|
-
if (startable.length > 0 || newComments > 0 || unassigned > 0 || resumable > 0) {
|
|
3585
|
-
const parts = [];
|
|
3586
|
-
if (startable.length) parts.push(`${startable.length} startable`);
|
|
3587
|
-
if (newComments) parts.push(`${newComments} new comment(s)`);
|
|
3588
|
-
if (unassigned) parts.push(`${unassigned} unassigned in_progress`);
|
|
3589
|
-
if (resumable) parts.push(`${resumable} resumable in_progress`);
|
|
3590
|
-
return { hasWork: true, reason: parts.join(", ") };
|
|
3325
|
+
/**
|
|
3326
|
+
* One row of the `Projects` (area) table. `undefined` on a workspace
|
|
3327
|
+
* with no such table, or if the row is gone.
|
|
3328
|
+
*/
|
|
3329
|
+
async projectRow(id) {
|
|
3330
|
+
const model = this.route.resolved?.areaModelId;
|
|
3331
|
+
if (!model) return void 0;
|
|
3332
|
+
try {
|
|
3333
|
+
return await this.client.records.get(model, id);
|
|
3334
|
+
} catch {
|
|
3335
|
+
return void 0;
|
|
3336
|
+
}
|
|
3591
3337
|
}
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
return n > 0 ? { hasWork: true, reason: `${n} ticket(s) awaiting or in verification` } : { hasWork: false, reason: "nothing awaiting verification" };
|
|
3598
|
-
}
|
|
3599
|
-
function triageRoleHasWork(i) {
|
|
3600
|
-
if (!i.seats.triage) return { hasWork: false, reason: "this ship does not crew that role" };
|
|
3601
|
-
const n = triageSlice(i.tickets, i.seats.triage).length;
|
|
3602
|
-
return n > 0 ? { hasWork: true, reason: `${n} report(s) assigned to triage` } : { hasWork: false, reason: "nothing assigned to triage" };
|
|
3603
|
-
}
|
|
3604
|
-
function roleHasWork(role, i) {
|
|
3605
|
-
if (i.paused?.has(role)) return { hasWork: false, reason: "paused" };
|
|
3606
|
-
if (role === "qa") return qaRoleHasWork(i);
|
|
3607
|
-
if (role === "triage") return triageRoleHasWork(i);
|
|
3608
|
-
if (role === "pair") return { hasWork: false, reason: "pair is interactive, not polled" };
|
|
3609
|
-
return buildingRoleHasWork(role, i);
|
|
3610
|
-
}
|
|
3611
|
-
function roleCandidates(role, i) {
|
|
3612
|
-
if (role === "qa") return notHeldByOtherShip(qaSlice(i.tickets), i).filter((t) => !isHeld(t, i.holds));
|
|
3613
|
-
if (role === "triage") return triageSlice(i.tickets, i.seats.triage).filter((t) => !isHeld(t, i.holds));
|
|
3614
|
-
if (role === "pair") return [];
|
|
3615
|
-
const me = i.seats[role];
|
|
3616
|
-
if (!me) return [];
|
|
3617
|
-
return notHeldByOtherShip(sliceFor(i.tickets, role), i).filter(
|
|
3618
|
-
(t) => ((t.status === "accepted" || t.status === "blocked") && !i.blocked.has(t.id) || t.status === "in_progress" && (t.assignee_id === me || !t.assignee_id)) && !isHeld(t, i.holds)
|
|
3619
|
-
);
|
|
3620
|
-
}
|
|
3621
|
-
function claimStatuses(role, c) {
|
|
3622
|
-
return role === "qa" ? { approved: c.statuses.handoff, building: c.statuses.verifying } : { approved: c.statuses.approved, building: c.statuses.building };
|
|
3623
|
-
}
|
|
3624
|
-
function rankedCandidates(role, i) {
|
|
3625
|
-
const c = i.contract ?? DEFAULT_CONTRACT;
|
|
3626
|
-
return [...roleCandidates(role, i)].sort((a, b) => rankScalar(a, c) - rankScalar(b, c));
|
|
3627
|
-
}
|
|
3628
|
-
function roleTopRank(role, i) {
|
|
3629
|
-
if (role !== "qa" && role !== "triage" && !i.seats[role]) return NOTHING_ACTIONABLE;
|
|
3630
|
-
const candidates = roleCandidates(role, i);
|
|
3631
|
-
if (candidates.length === 0) return NOTHING_ACTIONABLE;
|
|
3632
|
-
const c = i.contract ?? DEFAULT_CONTRACT;
|
|
3633
|
-
return Math.min(...candidates.map((t) => rankScalar(t, c)));
|
|
3634
|
-
}
|
|
3635
|
-
function actionableSummary(i, pending) {
|
|
3636
|
-
const c = i.contract ?? DEFAULT_CONTRACT;
|
|
3637
|
-
const all = pending.flatMap((role) => roleCandidates(role, i));
|
|
3638
|
-
if (all.length === 0) return { count: 0 };
|
|
3639
|
-
const top = all.reduce((best, t) => rankScalar(t, c) < rankScalar(best, c) ? t : best);
|
|
3640
|
-
return { count: all.length, top };
|
|
3641
|
-
}
|
|
3642
|
-
var FIXED_RANK = { qa: -2, triage: -1 };
|
|
3643
|
-
function selectRole(i) {
|
|
3644
|
-
const order = ["qa", "triage", "dev", "design"];
|
|
3645
|
-
const pending = [];
|
|
3646
|
-
const ranks = {};
|
|
3647
|
-
const reasons = {};
|
|
3648
|
-
for (const role of order) {
|
|
3649
|
-
const { hasWork, reason } = roleHasWork(role, i);
|
|
3650
|
-
reasons[role] = reason;
|
|
3651
|
-
if (hasWork) pending.push(role);
|
|
3338
|
+
/** Every `Projects` (area) row — what `crew repos add` offers when a new Repos row needs one. */
|
|
3339
|
+
async projectRows() {
|
|
3340
|
+
const model = this.route.resolved?.areaModelId;
|
|
3341
|
+
if (!model) return [];
|
|
3342
|
+
return this.client.records.list(model, { limit: 200 });
|
|
3652
3343
|
}
|
|
3653
|
-
|
|
3654
|
-
|
|
3655
|
-
|
|
3656
|
-
|
|
3657
|
-
|
|
3658
|
-
|
|
3659
|
-
|
|
3660
|
-
|
|
3661
|
-
|
|
3344
|
+
/** Create a `Repos` row. Throws when the workspace has no Repos table. */
|
|
3345
|
+
async createRepoRow(fields) {
|
|
3346
|
+
const model = this.route.resolved?.reposModelId;
|
|
3347
|
+
if (!model) throw new Error("this workspace has no Repos table");
|
|
3348
|
+
return this.client.records.create(model, fields);
|
|
3349
|
+
}
|
|
3350
|
+
/**
|
|
3351
|
+
* The Ships rows of this workspace, if it has a Ships table.
|
|
3352
|
+
*
|
|
3353
|
+
* Optional by design: a workspace that has not adopted ships still works,
|
|
3354
|
+
* and the crew must not require a table it did not create.
|
|
3355
|
+
*/
|
|
3356
|
+
async shipRows() {
|
|
3357
|
+
const model = this.route.resolved?.shipsModelId;
|
|
3358
|
+
if (!model) return [];
|
|
3359
|
+
try {
|
|
3360
|
+
return await this.client.records.list(model, { limit: 200 });
|
|
3361
|
+
} catch {
|
|
3362
|
+
return [];
|
|
3363
|
+
}
|
|
3364
|
+
}
|
|
3365
|
+
/**
|
|
3366
|
+
* This ship's own row on the `Ships` table, matched by name — the same
|
|
3367
|
+
* exact-string match `doctor` has always used (see the callsign comment
|
|
3368
|
+
* on ISSUE-380: name is what an operator recognises on the board).
|
|
3369
|
+
* `undefined` when the workspace has no Ships table, no row named for
|
|
3370
|
+
* this ship, or more than one — ambiguous is not a row to write to.
|
|
3371
|
+
*/
|
|
3372
|
+
async myShipRow(shipName2) {
|
|
3373
|
+
const rows = await this.shipRows();
|
|
3374
|
+
const mine = rows.filter((r) => (r.name ?? "").trim() === shipName2.trim());
|
|
3375
|
+
return mine.length === 1 ? mine[0] : void 0;
|
|
3376
|
+
}
|
|
3377
|
+
/**
|
|
3378
|
+
* The `attention` field of a Ships-row patch (CREW-1373): this ship's open
|
|
3379
|
+
* items, so every heartbeat carries the current set and a cleared item
|
|
3380
|
+
* disappears from the board on the next one. Empty when there is nothing
|
|
3381
|
+
* to write: no state directory to read, or a Ships table without the
|
|
3382
|
+
* column (probed by the key's absence from the row, the way `owner_id` is
|
|
3383
|
+
* skipped on an install that has not taken the template update).
|
|
3384
|
+
*/
|
|
3385
|
+
attentionPatch(row) {
|
|
3386
|
+
if (!this.stateDir || !("attention" in row)) return {};
|
|
3387
|
+
const items = openShipAttention(new State(this.stateDir));
|
|
3388
|
+
return { attention: items.length ? items : null };
|
|
3389
|
+
}
|
|
3390
|
+
/**
|
|
3391
|
+
* Writes this ship's attention items on their own, without touching
|
|
3392
|
+
* `last_seen` — for the daemon, which raises one between heartbeats and
|
|
3393
|
+
* should not wait for the next engaged/idle beat to show it. Skips the
|
|
3394
|
+
* write when the row already holds the same set.
|
|
3395
|
+
*/
|
|
3396
|
+
async publishShipAttention(shipName2) {
|
|
3397
|
+
const row = await this.myShipRow(shipName2);
|
|
3398
|
+
if (!row) return;
|
|
3399
|
+
const patch2 = this.attentionPatch(row);
|
|
3400
|
+
if (!("attention" in patch2)) return;
|
|
3401
|
+
if (JSON.stringify(patch2.attention) === JSON.stringify(row.attention ?? null)) return;
|
|
3402
|
+
await this.client.records.update(this.route.resolved.shipsModelId, row.id, patch2);
|
|
3403
|
+
}
|
|
3404
|
+
/**
|
|
3405
|
+
* Ship-level heartbeat (ISSUE-380): "is this ship online" — independent
|
|
3406
|
+
* of any one seat's Working/Idle status (`setCrewStatus`), which only
|
|
3407
|
+
* exists for the duration of an actual agent run. This one fires once
|
|
3408
|
+
* per `crew run`/`poll` cycle **regardless of outcome**, including a
|
|
3409
|
+
* cycle that finds nothing to do, so a ship that stops firing (powered
|
|
3410
|
+
* off, asleep, closed for the weekend) simply stops updating `last_seen`
|
|
3411
|
+
* — silence then means exactly one thing, rather than being ambiguous
|
|
3412
|
+
* with "idle".
|
|
3413
|
+
*
|
|
3414
|
+
* Does not touch the `engaged*` columns — those are set only by
|
|
3415
|
+
* `beatEngaged`/`beatIdle`, paired around the part of the cycle that
|
|
3416
|
+
* actually spawns an agent, so a plain heartbeat mid-engagement can't
|
|
3417
|
+
* blow away `engaged_since`.
|
|
3418
|
+
*
|
|
3419
|
+
* A no-op, like `shipRows()`, on a workspace with no Ships table or no
|
|
3420
|
+
* row for this ship's name.
|
|
3421
|
+
*/
|
|
3422
|
+
async beatShip(shipName2) {
|
|
3423
|
+
const row = await this.myShipRow(shipName2);
|
|
3424
|
+
if (!row) return;
|
|
3425
|
+
await this.client.records.update(this.route.resolved.shipsModelId, row.id, {
|
|
3426
|
+
last_seen: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3427
|
+
host: hostname(),
|
|
3428
|
+
pid: process.pid,
|
|
3429
|
+
...this.attentionPatch(row)
|
|
3430
|
+
});
|
|
3431
|
+
}
|
|
3432
|
+
/**
|
|
3433
|
+
* Marks this ship's row engaged, naming what it's working and since when
|
|
3434
|
+
* (ISSUE-380's "an engaged ship names what it is working and since
|
|
3435
|
+
* when"). Call once, right before `spawnAgent`, paired with
|
|
3436
|
+
* `beatIdle` in a `finally` — the same shape as `setCrewStatus`'s
|
|
3437
|
+
* working/idle pair, one level up (ship, not seat).
|
|
3438
|
+
*/
|
|
3439
|
+
async beatEngaged(shipName2, connectionName, ticketRecordId) {
|
|
3440
|
+
const row = await this.myShipRow(shipName2);
|
|
3441
|
+
if (!row) return;
|
|
3442
|
+
await this.client.records.update(this.route.resolved.shipsModelId, row.id, {
|
|
3443
|
+
last_seen: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3444
|
+
host: hostname(),
|
|
3445
|
+
pid: process.pid,
|
|
3446
|
+
engaged: true,
|
|
3447
|
+
engaged_since: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3448
|
+
engaged_connection: connectionName,
|
|
3449
|
+
engaged_ticket_id: ticketRecordId,
|
|
3450
|
+
...this.attentionPatch(row)
|
|
3451
|
+
});
|
|
3452
|
+
}
|
|
3453
|
+
/**
|
|
3454
|
+
* Writes this ship's own tunnel state for Host Passengers (ISSUE-553):
|
|
3455
|
+
* `tunnel_status` (disconnected/connecting/connected), as
|
|
3456
|
+
* the tunnel client (`tunnel.ts`) brings a workspace's container's tunnel
|
|
3457
|
+
* up or down. A no-op, like every other `myShipRow`-backed write here, on
|
|
3458
|
+
* a workspace with no Ships table or no row for this ship's name — the
|
|
3459
|
+
* tunnel client itself still runs the tunnel either way, it just has
|
|
3460
|
+
* nowhere on the board to report the state.
|
|
3461
|
+
*/
|
|
3462
|
+
async updateTunnelState(shipName2, patch2) {
|
|
3463
|
+
const row = await this.myShipRow(shipName2);
|
|
3464
|
+
if (!row) return;
|
|
3465
|
+
await this.client.records.update(this.route.resolved.shipsModelId, row.id, patch2);
|
|
3466
|
+
}
|
|
3467
|
+
/** The other half of `beatEngaged` — clears engagement back to idle. */
|
|
3468
|
+
async beatIdle(shipName2) {
|
|
3469
|
+
const row = await this.myShipRow(shipName2);
|
|
3470
|
+
if (!row) return;
|
|
3471
|
+
await this.client.records.update(this.route.resolved.shipsModelId, row.id, {
|
|
3472
|
+
last_seen: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3473
|
+
host: hostname(),
|
|
3474
|
+
pid: process.pid,
|
|
3475
|
+
engaged: false,
|
|
3476
|
+
engaged_since: null,
|
|
3477
|
+
engaged_connection: null,
|
|
3478
|
+
engaged_ticket_id: null,
|
|
3479
|
+
...this.attentionPatch(row)
|
|
3480
|
+
});
|
|
3481
|
+
}
|
|
3482
|
+
/**
|
|
3483
|
+
* The Epics rows of this workspace, if it has an Epics table (ISSUE-384).
|
|
3484
|
+
*
|
|
3485
|
+
* Optional by design, the same way `shipRows()` is: a workspace that has
|
|
3486
|
+
* not adopted epics still works, and the crew must not require a table it
|
|
3487
|
+
* did not create.
|
|
3488
|
+
*/
|
|
3489
|
+
async epicRows() {
|
|
3490
|
+
const model = this.route.resolved?.epicsModelId;
|
|
3491
|
+
if (!model) return [];
|
|
3492
|
+
try {
|
|
3493
|
+
return await this.client.records.list(model, { limit: 500 });
|
|
3494
|
+
} catch {
|
|
3495
|
+
return [];
|
|
3496
|
+
}
|
|
3497
|
+
}
|
|
3498
|
+
/**
|
|
3499
|
+
* The tickets of these epics that are closed (`closed: true`) or not
|
|
3500
|
+
* (`closed: false`), for the epic sync (CREW-1255).
|
|
3501
|
+
*
|
|
3502
|
+
* **Not area-filtered, unlike `openTickets()`**: an epic spans areas, so
|
|
3503
|
+
* whether it is finished can only be judged across all of them. Also not
|
|
3504
|
+
* limited to `statuses.open` — a status this contract has never heard of
|
|
3505
|
+
* (a pre-triage `draft`, say) is still a ticket that is not closed.
|
|
3506
|
+
*
|
|
3507
|
+
* Paged, because an epic's history is unbounded and a truncated page
|
|
3508
|
+
* would silently read as "nothing else outstanding".
|
|
3509
|
+
*/
|
|
3510
|
+
async epicTickets(epicIds, o) {
|
|
3511
|
+
if (epicIds.length === 0) return [];
|
|
3512
|
+
const c = this.contract;
|
|
3513
|
+
const statuses = closedStatuses(c);
|
|
3514
|
+
if (!statuses.length) return [];
|
|
3515
|
+
const filters = encodeFilters([
|
|
3516
|
+
{ columnName: c.columns.epic, operator: "IN", value: epicIds },
|
|
3517
|
+
{ columnName: c.columns.status, operator: o.closed ? "IN" : "NOT_IN", value: statuses }
|
|
3518
|
+
]);
|
|
3519
|
+
const pageSize = 500;
|
|
3520
|
+
const out = [];
|
|
3521
|
+
for (let page = 0; page < 20; page++) {
|
|
3522
|
+
const rows = await this.client.records.list(this.models.issues, {
|
|
3523
|
+
filters,
|
|
3524
|
+
limit: pageSize,
|
|
3525
|
+
offset: page * pageSize
|
|
3526
|
+
});
|
|
3527
|
+
out.push(...rows);
|
|
3528
|
+
if (rows.length < pageSize) break;
|
|
3529
|
+
}
|
|
3530
|
+
return out;
|
|
3531
|
+
}
|
|
3532
|
+
/** Conditional on `expectedUpdatedAt`, exactly as `updateTicket` is. */
|
|
3533
|
+
async updateEpic(id, patch2, expectedUpdatedAt) {
|
|
3534
|
+
const model = this.route.resolved?.epicsModelId;
|
|
3535
|
+
if (!model) throw new ConfigError(`route "${this.route.route}" has no Epics table`);
|
|
3536
|
+
return this.client.records.update(model, id, patch2, expectedUpdatedAt);
|
|
3537
|
+
}
|
|
3538
|
+
/**
|
|
3539
|
+
* `expectedUpdatedAt` makes this a conditional write (ISSUE-395, the same
|
|
3540
|
+
* `RecordsResource.update` primitive board-lock.ts uses): pass the
|
|
3541
|
+
* `updated_at` this caller last read, and the write throws
|
|
3542
|
+
* `StaleWriteError` instead of applying if the row has moved since —
|
|
3543
|
+
* exactly what claim.ts needs to let a contended ticket claim fail loudly
|
|
3544
|
+
* rather than silently overwrite a winning ship's write.
|
|
3545
|
+
*/
|
|
3546
|
+
async updateTicket(id, patch2, expectedUpdatedAt) {
|
|
3547
|
+
return this.client.records.update(this.models.issues, id, patch2, expectedUpdatedAt);
|
|
3548
|
+
}
|
|
3549
|
+
/**
|
|
3550
|
+
* Claim a board-visible lock for `scope` (ISSUE-394) — see board-lock.ts
|
|
3551
|
+
* for the CAS mechanics. Resolves to `{ ok: true }` immediately, with a
|
|
3552
|
+
* no-op release, only when this workspace has no Locks table at all. A
|
|
3553
|
+
* Locks table with no row for `scope` is `{ ok: false, reason: 'missing' }`
|
|
3554
|
+
* (CREW-1384): `crew connect` provisions the row.
|
|
3555
|
+
*/
|
|
3556
|
+
acquireBoardLock(scope, holderLabel, ttlMs) {
|
|
3557
|
+
return acquireBoardLock(this.client.records, this.route.resolved?.locksModelId, scope, holderLabel, ttlMs);
|
|
3558
|
+
}
|
|
3559
|
+
/** The Locks row for `scope` — present? held by whom? Undefined when this workspace has no Locks table. */
|
|
3560
|
+
async lockState(scope) {
|
|
3561
|
+
const model = this.route.resolved?.locksModelId;
|
|
3562
|
+
return model ? lockRowState(this.client.records, model, scope) : void 0;
|
|
3563
|
+
}
|
|
3564
|
+
/**
|
|
3565
|
+
* Reports one crew member's live status (ISSUE-325) — "Working" for the
|
|
3566
|
+
* duration of an agent run, "Idle" once it ends.
|
|
3567
|
+
*
|
|
3568
|
+
* `ticketRecordId` is the ticket's own row id (`Ticket.id`), not its
|
|
3569
|
+
* `issue_id` label — the field it fills is a Reference, not text.
|
|
3570
|
+
* Omitted (as on the idle transition) leaves the column alone, so it
|
|
3571
|
+
* keeps reading as "current/last issue" rather than clearing to blank
|
|
3572
|
+
* the moment a run ends.
|
|
3573
|
+
*/
|
|
3574
|
+
async setCrewStatus(memberId, status2, ticketRecordId) {
|
|
3575
|
+
const patch2 = { status: status2, status_updated_at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
3576
|
+
if (ticketRecordId !== void 0) patch2.current_issue_id = ticketRecordId;
|
|
3577
|
+
await this.client.records.update(this.models.crew, memberId, patch2);
|
|
3578
|
+
}
|
|
3579
|
+
/**
|
|
3580
|
+
* An `event` comment — the runner's own audit trail, distinct from a
|
|
3581
|
+
* seat's prose. `kind` is what keeps these out of the "new comment from
|
|
3582
|
+
* someone else" wake signal.
|
|
3583
|
+
*/
|
|
3584
|
+
async postEvent(ticketId, body, memberId) {
|
|
3585
|
+
const auth = await this.authorshipProbe();
|
|
3586
|
+
await this.client.records.create(this.models.comments, {
|
|
3587
|
+
ticket_id: ticketId,
|
|
3588
|
+
body,
|
|
3589
|
+
team_member_id: memberId,
|
|
3590
|
+
kind: "event",
|
|
3591
|
+
...auth.commentShip && auth.shipId ? { [this.contract.comments.ship]: auth.shipId } : {}
|
|
3592
|
+
});
|
|
3593
|
+
}
|
|
3594
|
+
/**
|
|
3595
|
+
* Ship affinity for a claim (CREW-1386): stamp this ship's Ships row on the
|
|
3596
|
+
* ticket in the claim's own write, in the `held_by_ship_id` column (not
|
|
3597
|
+
* `ship_id`, which means "filed from"). `undefined` — claim exactly as
|
|
3598
|
+
* before — when the Issues table has no hold column or this ship has no row.
|
|
3599
|
+
*/
|
|
3600
|
+
async claimAffinity() {
|
|
3601
|
+
const auth = await this.authorshipProbe();
|
|
3602
|
+
if (!auth.issueHeld || !auth.shipId) return void 0;
|
|
3603
|
+
return {
|
|
3604
|
+
column: this.contract.columns.heldBy,
|
|
3605
|
+
shipId: auth.shipId
|
|
3606
|
+
};
|
|
3607
|
+
}
|
|
3608
|
+
/**
|
|
3609
|
+
* The authorship probe (CREW-1371), read once per process: which optional
|
|
3610
|
+
* `filed_by_id`/`ship_id` columns exist on Issues and Comments, and this
|
|
3611
|
+
* ship's own Ships row id. Memoised as a promise so concurrent first writes
|
|
3612
|
+
* share one round of metadata reads. A table whose metadata cannot be read
|
|
3613
|
+
* reads as "column absent", the same way `connect.ts`'s `owner_id` probe does.
|
|
3614
|
+
*/
|
|
3615
|
+
authorshipProbe() {
|
|
3616
|
+
this.authorship ??= this.probeAuthorship();
|
|
3617
|
+
return this.authorship;
|
|
3618
|
+
}
|
|
3619
|
+
async probeAuthorship() {
|
|
3620
|
+
const [issueFields, commentFields, shipRow] = await Promise.all([
|
|
3621
|
+
this.columnNames(this.models.issues),
|
|
3622
|
+
this.columnNames(this.models.comments),
|
|
3623
|
+
// No name (a caller that only beats or patches, never stamps) means no row to find.
|
|
3624
|
+
this.shipName ? this.myShipRow(this.shipName) : Promise.resolve(void 0)
|
|
3625
|
+
]);
|
|
3626
|
+
return {
|
|
3627
|
+
issueAuthor: issueFields.has(this.contract.columns.author),
|
|
3628
|
+
issueShip: issueFields.has(this.contract.columns.ship),
|
|
3629
|
+
issueHeld: issueFields.has(this.contract.columns.heldBy),
|
|
3630
|
+
commentShip: commentFields.has(this.contract.comments.ship),
|
|
3631
|
+
shipId: shipRow?.id ?? null
|
|
3632
|
+
};
|
|
3633
|
+
}
|
|
3634
|
+
async columnNames(modelId) {
|
|
3635
|
+
try {
|
|
3636
|
+
const model = await this.client.dataModels.get(modelId);
|
|
3637
|
+
return new Set(model.fields.map((f) => f.columnName));
|
|
3638
|
+
} catch {
|
|
3639
|
+
return /* @__PURE__ */ new Set();
|
|
3640
|
+
}
|
|
3641
|
+
}
|
|
3642
|
+
/**
|
|
3643
|
+
* The authorship columns this workspace is missing, as `table.column` names
|
|
3644
|
+
* for `crew doctor` to report (CREW-1371). Empty on a fully updated install.
|
|
3645
|
+
*/
|
|
3646
|
+
async missingAuthorshipColumns() {
|
|
3647
|
+
const auth = await this.authorshipProbe();
|
|
3648
|
+
const missing = [];
|
|
3649
|
+
if (!auth.issueAuthor) missing.push(`Issues.${this.contract.columns.author}`);
|
|
3650
|
+
if (!auth.issueShip) missing.push(`Issues.${this.contract.columns.ship}`);
|
|
3651
|
+
if (!auth.issueHeld) missing.push(`Issues.${this.contract.columns.heldBy}`);
|
|
3652
|
+
if (!auth.commentShip) missing.push(`Comments.${this.contract.comments.ship}`);
|
|
3653
|
+
return missing;
|
|
3654
|
+
}
|
|
3655
|
+
/**
|
|
3656
|
+
* The public `/v/<slug>/r/<token>` link for one ticket (ISSUE-928) —
|
|
3657
|
+
* fetch-or-create via `POST /views/:viewId/record-links`, so a repeated
|
|
3658
|
+
* call for the same (view, model, record) returns the same URL and even
|
|
3659
|
+
* un-retires a retired one — right for a ticket that just went back to
|
|
3660
|
+
* `needs_info` after having been resolved once before.
|
|
3661
|
+
*
|
|
3662
|
+
* `undefined` when this route has no `recordLinkViewId` resolved, or the
|
|
3663
|
+
* mint call fails for any reason (network, 404, an unexpected response
|
|
3664
|
+
* shape) — the caller (poll.ts's attention sweep) still emits its
|
|
3665
|
+
* notification event without a link rather than losing the whole event.
|
|
3666
|
+
*/
|
|
3667
|
+
async recordLinkUrl(recordId) {
|
|
3668
|
+
const viewId = this.route.resolved?.recordLinkViewId;
|
|
3669
|
+
if (!viewId) return void 0;
|
|
3670
|
+
try {
|
|
3671
|
+
const res = await fetch(`${this.route.baseUrl}/api/views/${viewId}/record-links`, {
|
|
3672
|
+
method: "POST",
|
|
3673
|
+
headers: {
|
|
3674
|
+
Authorization: `Bearer ${resolveApiKey(this.route)}`,
|
|
3675
|
+
"User-Agent": this.userAgent,
|
|
3676
|
+
"Content-Type": "application/json"
|
|
3677
|
+
},
|
|
3678
|
+
body: JSON.stringify({ dataModelId: this.models.issues, recordId })
|
|
3679
|
+
});
|
|
3680
|
+
if (!res.ok) return void 0;
|
|
3681
|
+
const body = await res.json();
|
|
3682
|
+
return typeof body.url === "string" ? body.url : void 0;
|
|
3683
|
+
} catch {
|
|
3684
|
+
return void 0;
|
|
3662
3685
|
}
|
|
3663
3686
|
}
|
|
3664
|
-
|
|
3665
|
-
|
|
3666
|
-
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3687
|
+
/**
|
|
3688
|
+
* File a brand new ticket. Unlike every other write on this class, the
|
|
3689
|
+
* field names are the fixed Bug Reports schema (`title`, `description`,
|
|
3690
|
+
* `report_type`, `severity`, `reporter_name`) rather than anything in
|
|
3691
|
+
* `Contract.columns` — that contract only names the lifecycle fields a
|
|
3692
|
+
* workspace can rename, and creation-only fields like `description` are
|
|
3693
|
+
* not among them (`postEvent` above hardcodes its own fields the same
|
|
3694
|
+
* way).
|
|
3695
|
+
*/
|
|
3696
|
+
async fileTicket(fields, memberId) {
|
|
3697
|
+
const auth = await this.authorshipProbe();
|
|
3698
|
+
return this.client.records.create(this.models.issues, {
|
|
3699
|
+
...fields,
|
|
3700
|
+
...auth.issueAuthor && memberId ? { [this.contract.columns.author]: memberId } : {},
|
|
3701
|
+
...auth.issueShip && auth.shipId ? { [this.contract.columns.ship]: auth.shipId } : {}
|
|
3702
|
+
});
|
|
3703
|
+
}
|
|
3676
3704
|
};
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
const
|
|
3683
|
-
const
|
|
3684
|
-
return
|
|
3685
|
-
const status2 = statusOf(e, c);
|
|
3686
|
-
return status2 !== null && movable.has(status2);
|
|
3687
|
-
});
|
|
3688
|
-
}
|
|
3689
|
-
function workedStatuses(c) {
|
|
3690
|
-
const s = c.statuses;
|
|
3691
|
-
return new Set(
|
|
3692
|
-
[s.building, s.handoff, s.verifying, s.verified, s.reviewing].filter((v) => !!v)
|
|
3693
|
-
);
|
|
3694
|
-
}
|
|
3695
|
-
function epicsNeedingClosedCheck(epics, outstanding, c) {
|
|
3696
|
-
const hasOutstanding = new Set(outstanding.map((t) => epicOf(t, c)));
|
|
3697
|
-
return trackedEpics(epics, c).filter((e) => statusOf(e, c) !== c.epics.statuses.done && !hasOutstanding.has(e.id)).map((e) => e.id);
|
|
3705
|
+
|
|
3706
|
+
// src/blocked.ts
|
|
3707
|
+
import { StaleWriteError as StaleWriteError2 } from "@tablation/client";
|
|
3708
|
+
function missingBlockerIds(tickets) {
|
|
3709
|
+
const known = new Set(tickets.map((t) => t.id));
|
|
3710
|
+
const missing = /* @__PURE__ */ new Set();
|
|
3711
|
+
for (const t of tickets) for (const id of t.blocked_by ?? []) if (!known.has(id)) missing.add(id);
|
|
3712
|
+
return [...missing];
|
|
3698
3713
|
}
|
|
3699
|
-
function
|
|
3700
|
-
const
|
|
3701
|
-
const
|
|
3702
|
-
|
|
3703
|
-
const openByEpic = /* @__PURE__ */ new Map();
|
|
3704
|
-
for (const t of outstanding) {
|
|
3705
|
-
const id = epicOf(t, c);
|
|
3706
|
-
if (!id || isClosed.has(t.status)) continue;
|
|
3707
|
-
openByEpic.set(id, [...openByEpic.get(id) ?? [], t]);
|
|
3714
|
+
function blockerInfoMap(tickets, extra = []) {
|
|
3715
|
+
const info = {};
|
|
3716
|
+
for (const t of [...tickets, ...extra]) {
|
|
3717
|
+
info[t.id] = { issue_id: t.issue_id, issue_tag: t.issue_tag, status: t.status };
|
|
3708
3718
|
}
|
|
3709
|
-
|
|
3710
|
-
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
3719
|
+
return info;
|
|
3720
|
+
}
|
|
3721
|
+
function computeBlockedIds(tickets, info) {
|
|
3722
|
+
const blocked = /* @__PURE__ */ new Set();
|
|
3723
|
+
for (const t of tickets) {
|
|
3724
|
+
const unresolved = (t.blocked_by ?? []).some((id) => {
|
|
3725
|
+
const status2 = info[id]?.status;
|
|
3726
|
+
return status2 !== void 0 && !RESOLVED_STATUSES.has(status2);
|
|
3727
|
+
});
|
|
3728
|
+
if (unresolved) blocked.add(t.id);
|
|
3714
3729
|
}
|
|
3715
|
-
|
|
3716
|
-
|
|
3717
|
-
|
|
3718
|
-
|
|
3719
|
-
|
|
3720
|
-
|
|
3721
|
-
|
|
3722
|
-
if (
|
|
3723
|
-
|
|
3724
|
-
action: "complete",
|
|
3725
|
-
epic,
|
|
3726
|
-
label,
|
|
3727
|
-
from,
|
|
3728
|
-
to: s.done,
|
|
3729
|
-
reason: n === 1 ? "its only ticket is closed" : `all ${n} tickets closed`
|
|
3730
|
-
});
|
|
3731
|
-
}
|
|
3732
|
-
continue;
|
|
3733
|
-
}
|
|
3734
|
-
const active = open.filter((t) => worked.has(t.status));
|
|
3735
|
-
if (active.length && from !== s.building) {
|
|
3736
|
-
steps.push({
|
|
3737
|
-
action: from === s.done ? "reopen" : "start",
|
|
3738
|
-
epic,
|
|
3739
|
-
label,
|
|
3740
|
-
from,
|
|
3741
|
-
to: s.building,
|
|
3742
|
-
reason: active.map((t) => `${t.issue_tag ?? t.issue_id} (${t.status})`).join(", ")
|
|
3743
|
-
});
|
|
3730
|
+
return blocked;
|
|
3731
|
+
}
|
|
3732
|
+
function sweepDiagnostics(tickets, info) {
|
|
3733
|
+
const dangling = [];
|
|
3734
|
+
const selfBlocked = [];
|
|
3735
|
+
for (const t of tickets) {
|
|
3736
|
+
for (const id of t.blocked_by ?? []) {
|
|
3737
|
+
if (info[id] === void 0) dangling.push(`${t.issue_id} -> ${id}`);
|
|
3738
|
+
if (id === t.id) selfBlocked.push(t.issue_id);
|
|
3744
3739
|
}
|
|
3745
3740
|
}
|
|
3746
|
-
return
|
|
3741
|
+
return { dangling, selfBlocked: [...new Set(selfBlocked)] };
|
|
3747
3742
|
}
|
|
3748
|
-
var
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
await writer.updateEpic(step.epic.id, { [c.epics.statusColumn]: step.to }, step.epic.updated_at);
|
|
3757
|
-
updated++;
|
|
3758
|
-
log.emit(describeEpicStep(step), { step: "sweep", data });
|
|
3759
|
-
} catch (e) {
|
|
3760
|
-
if (e instanceof StaleWriteError3) {
|
|
3761
|
-
contended++;
|
|
3762
|
-
log.emit(`epic ${step.label} changed since it was read \u2014 left for the next cycle`, { step: "sweep", data });
|
|
3763
|
-
continue;
|
|
3764
|
-
}
|
|
3765
|
-
failed++;
|
|
3766
|
-
log.warn(`could not move epic ${step.label} to ${step.to}: ${e.message}`, { step: "sweep", data });
|
|
3743
|
+
var describeBlockers = (t, info) => (t.blocked_by ?? []).map((id) => `${info[id]?.issue_id ?? "?"} (${info[id]?.status ?? "unknown"})`).join(", ");
|
|
3744
|
+
function planSweep(tickets, info, blocked) {
|
|
3745
|
+
const steps = [];
|
|
3746
|
+
for (const t of tickets) {
|
|
3747
|
+
if (t.status === "accepted" && blocked.has(t.id)) {
|
|
3748
|
+
steps.push({ action: "park", ticket: t, blockers: describeBlockers(t, info), to: "blocked" });
|
|
3749
|
+
} else if (t.status === "blocked" && !blocked.has(t.id)) {
|
|
3750
|
+
steps.push({ action: "restore", ticket: t, blockers: describeBlockers(t, info), to: "accepted" });
|
|
3767
3751
|
}
|
|
3768
3752
|
}
|
|
3769
|
-
return
|
|
3753
|
+
return steps;
|
|
3770
3754
|
}
|
|
3771
|
-
|
|
3772
|
-
|
|
3773
|
-
|
|
3774
|
-
|
|
3775
|
-
if (ticket.status === contract.statuses.needsHuman) reasons.push("needs_info");
|
|
3776
|
-
if (ticket.needs_planning === true) reasons.push("needs_planning");
|
|
3777
|
-
if (ticket.needs_review === true) reasons.push("needs_review");
|
|
3778
|
-
return reasons;
|
|
3755
|
+
function strandedNeedsInfo(tickets, blocked, holds2 = /* @__PURE__ */ new Set()) {
|
|
3756
|
+
return tickets.filter(
|
|
3757
|
+
(t) => t.status === "needs_info" && (t.blocked_by ?? []).length > 0 && !blocked.has(t.id) && !(t.assignee_id && holds2.has(t.assignee_id))
|
|
3758
|
+
);
|
|
3779
3759
|
}
|
|
3780
|
-
|
|
3781
|
-
|
|
3782
|
-
|
|
3783
|
-
|
|
3784
|
-
|
|
3785
|
-
|
|
3760
|
+
var COORDINATING_DEFAULTS = {
|
|
3761
|
+
reportTypeColumn: "report_type",
|
|
3762
|
+
coordinatingValue: "coordinating"
|
|
3763
|
+
};
|
|
3764
|
+
var isCoordinating = (t, opts) => t[opts.reportTypeColumn] === opts.coordinatingValue;
|
|
3765
|
+
var FILING_ERROR_DEFAULT_EXEMPT = ["coordinating", "question", "investigation"];
|
|
3766
|
+
function rollUpParents(tickets, done = RESOLVED_STATUSES, opts = {}) {
|
|
3767
|
+
const resolved = { ...COORDINATING_DEFAULTS, ...opts };
|
|
3768
|
+
const byParent = /* @__PURE__ */ new Map();
|
|
3769
|
+
for (const t of tickets) {
|
|
3770
|
+
const p = t.parent_id;
|
|
3771
|
+
if (!p) continue;
|
|
3772
|
+
const list = byParent.get(p);
|
|
3773
|
+
if (list) list.push(t);
|
|
3774
|
+
else byParent.set(p, [t]);
|
|
3786
3775
|
}
|
|
3787
|
-
|
|
3776
|
+
const rollups = [];
|
|
3777
|
+
for (const t of tickets) {
|
|
3778
|
+
const children = byParent.get(t.id);
|
|
3779
|
+
if (!isCoordinating(t, resolved) || !children) continue;
|
|
3780
|
+
const outstanding = children.filter((c) => !done.has(c.status));
|
|
3781
|
+
rollups.push({ parent: t, children, outstanding, complete: outstanding.length === 0 });
|
|
3782
|
+
}
|
|
3783
|
+
return rollups;
|
|
3788
3784
|
}
|
|
3785
|
+
function filingErrors(tickets, opts = {}) {
|
|
3786
|
+
const reportTypeColumn = opts.reportTypeColumn ?? COORDINATING_DEFAULTS.reportTypeColumn;
|
|
3787
|
+
const coordinatingValue = opts.coordinatingValue ?? COORDINATING_DEFAULTS.coordinatingValue;
|
|
3788
|
+
const exempt = new Set(
|
|
3789
|
+
opts.repoExemptValues ?? [coordinatingValue, ...FILING_ERROR_DEFAULT_EXEMPT.filter((v) => v !== "coordinating")]
|
|
3790
|
+
);
|
|
3791
|
+
return tickets.filter((t) => {
|
|
3792
|
+
if (t.repo_id) return false;
|
|
3793
|
+
return !exempt.has(t[reportTypeColumn]);
|
|
3794
|
+
});
|
|
3795
|
+
}
|
|
3796
|
+
function sweepComment(step) {
|
|
3797
|
+
return step.action === "park" ? `Parked as **Blocked** by the crew \u2014 waiting on: ${step.blockers}.
|
|
3789
3798
|
|
|
3790
|
-
|
|
3791
|
-
|
|
3792
|
-
|
|
3799
|
+
This is automatic and reversible: an approved ticket whose \`Blocked by\` entries are not all resolved is parked out of the queue, and restored on the first poll after the last one resolves. Nothing else about the ticket changes. A blocker counts as resolved at \`verified\`, \`closed_deployed\`, \`closed_wont_fix\` or \`closed_duplicate\` \u2014 not at \`fixed\`, which is still an unmerged branch awaiting QA.` : `Restored to **Approved** by the crew \u2014 every blocker has resolved${step.blockers && step.blockers !== "\u2014" ? ` (${step.blockers})` : ""}.
|
|
3800
|
+
|
|
3801
|
+
This restores the status it was approved at before being parked; it is back in the queue and a role may pick it up. If a blocker resolved as \`closed_wont_fix\` or \`closed_duplicate\` this ticket may still not be buildable as written \u2014 that is a call for a human, not the crew.`;
|
|
3793
3802
|
}
|
|
3794
|
-
function
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
|
|
3799
|
-
|
|
3800
|
-
|
|
3803
|
+
async function applySweep(writer, steps, memberId, log) {
|
|
3804
|
+
let parked = 0;
|
|
3805
|
+
let restored = 0;
|
|
3806
|
+
let failed = 0;
|
|
3807
|
+
let contended = 0;
|
|
3808
|
+
for (const step of steps) {
|
|
3809
|
+
try {
|
|
3810
|
+
await writer.updateTicket(step.ticket.id, { status: step.to }, step.ticket.updated_at);
|
|
3811
|
+
if (step.action === "park") parked++;
|
|
3812
|
+
else restored++;
|
|
3813
|
+
log.emit(
|
|
3814
|
+
step.action === "park" ? `parked (waiting on ${step.blockers})` : `restored to ${step.to} (blockers resolved: ${step.blockers || "none"})`,
|
|
3815
|
+
{ ticket: step.ticket.issue_id, step: "sweep" }
|
|
3816
|
+
);
|
|
3817
|
+
try {
|
|
3818
|
+
await writer.postEvent(step.ticket.id, sweepComment(step), memberId);
|
|
3819
|
+
} catch {
|
|
3820
|
+
log.warn("status changed but the note failed to post", { ticket: step.ticket.issue_id, step: "sweep" });
|
|
3821
|
+
}
|
|
3822
|
+
} catch (e) {
|
|
3823
|
+
if (e instanceof StaleWriteError2) {
|
|
3824
|
+
contended++;
|
|
3825
|
+
log.warn(`skipped ${step.action} \u2014 ticket changed since the sweep was planned`, {
|
|
3826
|
+
ticket: step.ticket.issue_id,
|
|
3827
|
+
step: "sweep"
|
|
3828
|
+
});
|
|
3829
|
+
continue;
|
|
3830
|
+
}
|
|
3831
|
+
failed++;
|
|
3832
|
+
log.warn(`could not ${step.action}: ${e.message}`, { ticket: step.ticket.issue_id, step: "sweep" });
|
|
3833
|
+
}
|
|
3801
3834
|
}
|
|
3802
|
-
return
|
|
3803
|
-
}
|
|
3804
|
-
function shipSuffix(c, i) {
|
|
3805
|
-
if (!c.ship_id || c.ship_id === i.myShipId) return "";
|
|
3806
|
-
const name = i.ships?.find((s) => s.id === c.ship_id)?.name;
|
|
3807
|
-
return name ? ` (${name})` : "";
|
|
3835
|
+
return { parked, restored, failed, contended };
|
|
3808
3836
|
}
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
const ship = t.ship_id && t.ship_id !== i.myShipId ? i.ships?.find((s) => s.id === t.ship_id)?.name : void 0;
|
|
3815
|
-
return ship ? `${label} (${ship})` : label;
|
|
3816
|
-
}
|
|
3817
|
-
return t.reporter_name ? `${t.reporter_name} (unverified)` : "\u2014";
|
|
3837
|
+
|
|
3838
|
+
// src/priority.ts
|
|
3839
|
+
var indexOf = (order) => Object.fromEntries(order.map((v, i) => [v, i]));
|
|
3840
|
+
function derivedPriority(t, c = DEFAULT_CONTRACT) {
|
|
3841
|
+
return indexOf(c.severityOrder)[t.severity ?? ""] ?? c.unknownPriorityRank;
|
|
3818
3842
|
}
|
|
3819
|
-
function
|
|
3820
|
-
|
|
3821
|
-
const last = forTicket[forTicket.length - 1];
|
|
3822
|
-
if (!last) return "\u2014";
|
|
3823
|
-
return `${last.created_at.slice(0, 16)}Z ${author(last, i)}`;
|
|
3843
|
+
function explicitPriority(t, c = DEFAULT_CONTRACT) {
|
|
3844
|
+
return indexOf(c.priorityOrder)[t.priority ?? ""] ?? null;
|
|
3824
3845
|
}
|
|
3825
|
-
function
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
)
|
|
3846
|
+
function effectivePriority(t, c = DEFAULT_CONTRACT) {
|
|
3847
|
+
const explicit = explicitPriority(t, c);
|
|
3848
|
+
const derived = derivedPriority(t, c);
|
|
3849
|
+
return explicit === null ? derived : Math.min(explicit, derived);
|
|
3829
3850
|
}
|
|
3830
|
-
function
|
|
3831
|
-
return
|
|
3851
|
+
function severityRank(t, c = DEFAULT_CONTRACT) {
|
|
3852
|
+
return indexOf(c.severityOrder)[t.severity ?? ""] ?? c.severityOrder.length;
|
|
3832
3853
|
}
|
|
3833
|
-
function
|
|
3834
|
-
|
|
3835
|
-
const
|
|
3836
|
-
return
|
|
3854
|
+
function issueNumber(t) {
|
|
3855
|
+
const id = t.issue_id ?? "ISSUE-0";
|
|
3856
|
+
const n = Number.parseInt(id.split("-")[1] ?? "0", 10);
|
|
3857
|
+
return Number.isNaN(n) ? 0 : n;
|
|
3837
3858
|
}
|
|
3838
|
-
function
|
|
3839
|
-
|
|
3840
|
-
if (t.assignee_id === i.me) return "you";
|
|
3841
|
-
const label = crewLabel(i.roster.get(t.assignee_id));
|
|
3842
|
-
if (label === null) return "someone off this ship";
|
|
3843
|
-
return isHold(i.roster, t.assignee_id) ? `**${label} \u2014 HOLD**` : label;
|
|
3859
|
+
function epicRank(t) {
|
|
3860
|
+
return t.epicInProgress ? 0 : 1;
|
|
3844
3861
|
}
|
|
3845
|
-
function
|
|
3846
|
-
|
|
3847
|
-
const info = i.blockerInfo[id];
|
|
3848
|
-
return `${info ? displayKey2(info) : "?"} (${info?.status ?? "unknown"})`;
|
|
3849
|
-
});
|
|
3850
|
-
return list.length === 0 ? "\u2014" : list.join(", ");
|
|
3862
|
+
function rank(t, c = DEFAULT_CONTRACT) {
|
|
3863
|
+
return [effectivePriority(t, c), severityRank(t, c), epicRank(t), issueNumber(t)];
|
|
3851
3864
|
}
|
|
3852
|
-
|
|
3853
|
-
|
|
3854
|
-
|
|
3855
|
-
|
|
3856
|
-
|
|
3857
|
-
|
|
3858
|
-
|
|
3859
|
-
|
|
3860
|
-
`;
|
|
3865
|
+
function compareRank(a, b, c = DEFAULT_CONTRACT) {
|
|
3866
|
+
const ra = rank(a, c);
|
|
3867
|
+
const rb = rank(b, c);
|
|
3868
|
+
for (let i = 0; i < ra.length; i++) {
|
|
3869
|
+
const d = ra[i] - rb[i];
|
|
3870
|
+
if (d !== 0) return d;
|
|
3871
|
+
}
|
|
3872
|
+
return 0;
|
|
3861
3873
|
}
|
|
3862
|
-
function
|
|
3863
|
-
const
|
|
3864
|
-
|
|
3865
|
-
const n = newFromOthers(i, t.id);
|
|
3866
|
-
const branch = i.branchFor ? i.branchFor(t) : "";
|
|
3867
|
-
const worktree = i.worktreeFor ? i.worktreeFor(t) : "";
|
|
3868
|
-
const dir = i.dirFor ? i.dirFor(t) : null;
|
|
3869
|
-
const repo = i.dirFor ? dir ?? "**NO CHECKOUT**" : "";
|
|
3870
|
-
return `| ${displayKey2(t)} | ${repo} | ${branch} | ${worktree} | ${t.status} | ${who(t, i)} | ${filedBy(t, i)} | ${t.severity ?? "\u2014"} | ${t.priority ?? "\u2014"} | p${effectivePriority(t)} | ${t.updated_at.slice(0, 16)}Z | ${lastComment(i, t.id)} | ${n > 0 ? `**${n} new**` : "\u2014"} |`;
|
|
3871
|
-
};
|
|
3872
|
-
const blockedRow = (t) => `| ${displayKey2(t)} | ${t.status} | ${who(t, i)} | p${effectivePriority(t)} | ${blockers(t, i)} |`;
|
|
3873
|
-
const blockedTable = (ts) => ts.length === 0 ? "\n_None._\n" : `
|
|
3874
|
-
| ticket | status | assignee | eff | blocked by |
|
|
3875
|
-
|---|---|---|---|---|
|
|
3876
|
-
${sorted(ts).map(blockedRow).join("\n")}
|
|
3877
|
-
`;
|
|
3878
|
-
const isBlocked = (t) => i.blocked.has(t.id);
|
|
3879
|
-
const step1 = i.tickets.filter(
|
|
3880
|
-
(t) => t.status === "in_progress" && (t.assignee_id === i.me || !t.assignee_id) || t.status === "needs_info"
|
|
3881
|
-
);
|
|
3882
|
-
const step2 = i.tickets.filter(
|
|
3883
|
-
(t) => (t.status === "accepted" || t.status === "blocked") && !isBlocked(t)
|
|
3884
|
-
);
|
|
3885
|
-
return stream([
|
|
3886
|
-
"## Current queue \u2014 built for you by the poll\n",
|
|
3887
|
-
"\nAlready filtered to your lane, and already ordered by the Step 2 rule.\nTicket bodies are deliberately omitted: fetch the full record of only the\nticket you actually pick up. **Do not re-fetch the whole tracker.** This\ndigest comes from the same API call the poll just made, moments ago.\n",
|
|
3888
|
-
'\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',
|
|
3889
|
-
"\n**`repo` is the checkout a ticket's work happens in** \u2014 an area spans\nseveral repositories, so cut the worktree beside THAT directory, not beside\nwhichever one you started in. A ticket marked **NO CHECKOUT** is not yours:\nthis ship has no clone of its repository, and another ship may serve it.\n\n**Use the `branch` and `worktree` columns verbatim.** They are that\nrepository's own naming convention, rendered for THIS ticket \u2014 cut the\nworktree at `<repo>/../<worktree>`, on branch `<branch>`. Do not derive\neither yourself, and prefer these over the Environment section's own worked\nexample if the two ever disagree \u2014 that example is illustrative only,\nrendered before any ticket is picked.\n",
|
|
3890
|
-
"\n### Step 1 \u2014 open tickets that may be yours to act on\n",
|
|
3891
|
-
"\n`fixed` tickets are deliberately absent: they belong to the QA lane.\n",
|
|
3892
|
-
table(step1, i, header, row),
|
|
3893
|
-
"\n### Step 2 \u2014 accepted tickets, in pick order\n",
|
|
3894
|
-
"\nWork the first one you are not required to skip. Tickets waiting on an\nunresolved dependency are already out of this table \u2014 see Blocked below.\n",
|
|
3895
|
-
table(step2, i, header, row),
|
|
3896
|
-
"\n### Blocked \u2014 waiting on a dependency, not yours to start\n",
|
|
3897
|
-
'\nListed rather than hidden so "nothing to do" stays distinguishable from\n"everything is parked". The loop parks and restores these itself, on every\npoll: an approved ticket whose `Blocked by` entries are unresolved moves to\n`blocked`, and back to `accepted` once the last one resolves. **Do not pick\none up, do not set or clear `blocked` by hand, and do not "unblock" one by\nediting its `Blocked by` field** \u2014 if a blocker looks wrong, say so in your\nrun summary. A blocker counts as resolved at `verified`, `closed_deployed`,\n`closed_wont_fix` or `closed_duplicate`; `fixed` is still an unmerged branch\nawaiting QA, so it does not count.\n',
|
|
3898
|
-
blockedTable(i.tickets.filter(isBlocked))
|
|
3899
|
-
]);
|
|
3874
|
+
function rankScalar(t, c = DEFAULT_CONTRACT) {
|
|
3875
|
+
const [p, s, e, n] = rank(t, c);
|
|
3876
|
+
return p * 1e9 + s * 1e7 + e * 1e6 + n;
|
|
3900
3877
|
}
|
|
3901
|
-
|
|
3902
|
-
|
|
3903
|
-
|
|
3904
|
-
|
|
3905
|
-
|
|
3906
|
-
|
|
3907
|
-
|
|
3908
|
-
|
|
3909
|
-
if (i.dirFor && i.dirFor(t) === null) return "\u2014";
|
|
3910
|
-
return i.worktreeFor ? i.worktreeFor(t) : "";
|
|
3911
|
-
};
|
|
3912
|
-
const builtBy = (t) => t.needs_design === true ? "design" : "dev";
|
|
3913
|
-
const row = (t) => {
|
|
3914
|
-
const n = newFromOthers(i, t.id);
|
|
3915
|
-
const dir = i.dirFor ? i.dirFor(t) : null;
|
|
3916
|
-
const repo = i.dirFor ? dir ?? "**NO CHECKOUT**" : "";
|
|
3917
|
-
return `| ${displayKey2(t)} | ${repo} | ${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"} |`;
|
|
3918
|
-
};
|
|
3919
|
-
return stream([
|
|
3920
|
-
"## Current queue \u2014 built for you by the poll\n",
|
|
3921
|
-
'\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',
|
|
3922
|
-
'\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',
|
|
3923
|
-
"\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",
|
|
3924
|
-
"\n### Still in verification \u2014 yours, unfinished (take these first)\n",
|
|
3925
|
-
table(i.tickets.filter((t) => t.status === "qa"), i, header, row),
|
|
3926
|
-
"\n### Awaiting verification, in pick order\n",
|
|
3927
|
-
table(i.tickets.filter((t) => t.status === "fixed"), i, header, row)
|
|
3928
|
-
]);
|
|
3878
|
+
var NOTHING_ACTIONABLE = 999999999999;
|
|
3879
|
+
var AGE_HOURS_TO_ERODE = 48;
|
|
3880
|
+
function agedRankScalar(rawRank, aging, now) {
|
|
3881
|
+
if (!aging) return rawRank;
|
|
3882
|
+
const elapsedHours = Math.max(0, (now - Date.parse(aging.since)) / 36e5);
|
|
3883
|
+
const weight = aging.weight ?? 1;
|
|
3884
|
+
const fraction = Math.min(1, elapsedHours * weight / AGE_HOURS_TO_ERODE);
|
|
3885
|
+
return Math.round(rawRank * (1 - fraction));
|
|
3929
3886
|
}
|
|
3930
3887
|
|
|
3931
|
-
// src/
|
|
3932
|
-
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
writeSync,
|
|
3939
|
-
statSync as statSync3,
|
|
3940
|
-
unlinkSync as unlinkSync2
|
|
3941
|
-
} from "fs";
|
|
3942
|
-
import { dirname as dirname4, basename as basename3 } from "path";
|
|
3943
|
-
var GitError = class extends Error {
|
|
3944
|
-
};
|
|
3945
|
-
function gitRaw(cwd, args) {
|
|
3946
|
-
try {
|
|
3947
|
-
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
3948
|
-
} catch (e) {
|
|
3949
|
-
const err = e;
|
|
3950
|
-
const why = [err.stderr, err.stdout].map((x) => (x ?? "").trim()).filter(Boolean).join(" / ");
|
|
3951
|
-
throw new GitError(`git ${args.join(" ")}: ${why || err.message.trim()}`);
|
|
3952
|
-
}
|
|
3888
|
+
// src/ship-affinity.ts
|
|
3889
|
+
var CYCLE_SECONDS = 120;
|
|
3890
|
+
var SHIP_DEAD_AFTER_CYCLES = 10;
|
|
3891
|
+
function shipIsAlive(ship, now) {
|
|
3892
|
+
if (!ship?.last_seen) return false;
|
|
3893
|
+
const seen = Date.parse(ship.last_seen);
|
|
3894
|
+
return Number.isFinite(seen) && now - seen <= SHIP_DEAD_AFTER_CYCLES * CYCLE_SECONDS * 1e3;
|
|
3953
3895
|
}
|
|
3954
|
-
function
|
|
3955
|
-
|
|
3956
|
-
|
|
3957
|
-
} catch (e) {
|
|
3958
|
-
const err = e;
|
|
3959
|
-
const why = [err.stderr, err.stdout].map((x) => (x ?? "").trim()).filter(Boolean).join(" / ");
|
|
3960
|
-
throw new GitError(`git ${args.join(" ")}: ${why || err.message.trim()}`);
|
|
3961
|
-
}
|
|
3896
|
+
function ticketHolderShipId(t, contract) {
|
|
3897
|
+
const v = t[contract.columns.heldBy];
|
|
3898
|
+
return typeof v === "string" && v ? v : null;
|
|
3962
3899
|
}
|
|
3963
|
-
function
|
|
3964
|
-
|
|
3965
|
-
|
|
3966
|
-
|
|
3967
|
-
return null;
|
|
3968
|
-
}
|
|
3900
|
+
function heldByOtherShip(t, a) {
|
|
3901
|
+
const statuses = a.contract.statuses;
|
|
3902
|
+
if (t.status === statuses.handoff) return false;
|
|
3903
|
+
return heldByLiveOtherShip(t, a);
|
|
3969
3904
|
}
|
|
3970
|
-
function
|
|
3971
|
-
|
|
3972
|
-
|
|
3973
|
-
|
|
3974
|
-
|
|
3905
|
+
function heldByLiveOtherShip(t, a) {
|
|
3906
|
+
if (!a.myShipId) return false;
|
|
3907
|
+
const holder = ticketHolderShipId(t, a.contract);
|
|
3908
|
+
if (!holder || holder === a.myShipId) return false;
|
|
3909
|
+
const owner = a.ships.find((s) => s.id === holder);
|
|
3910
|
+
return shipIsAlive(owner, a.now ?? Date.now());
|
|
3975
3911
|
}
|
|
3976
|
-
|
|
3977
|
-
|
|
3978
|
-
|
|
3979
|
-
return gitRaw(cwd, ["status", "--porcelain"]).split("\n").filter((l) => l.length > 3).map((l) => ({
|
|
3980
|
-
code: l.slice(0, 2),
|
|
3981
|
-
path: l.slice(3),
|
|
3982
|
-
untracked: l.startsWith("??")
|
|
3983
|
-
}));
|
|
3912
|
+
function shipName(ships, id) {
|
|
3913
|
+
if (!id) return "an unknown ship";
|
|
3914
|
+
return ships.find((s) => s.id === id)?.name?.trim() || id;
|
|
3984
3915
|
}
|
|
3985
|
-
function
|
|
3986
|
-
const
|
|
3987
|
-
return
|
|
3916
|
+
function shipIdByName(ships, name) {
|
|
3917
|
+
const mine = ships.filter((r) => (r.name ?? "").trim() === name.trim());
|
|
3918
|
+
return mine.length === 1 ? mine[0].id : null;
|
|
3988
3919
|
}
|
|
3989
|
-
function
|
|
3990
|
-
|
|
3991
|
-
|
|
3992
|
-
|
|
3920
|
+
async function noteReaccepted(tracker, result, seat, shipLabel) {
|
|
3921
|
+
if (!result.reaccepted || !result.ticket) return;
|
|
3922
|
+
try {
|
|
3923
|
+
await tracker.postEvent(
|
|
3924
|
+
result.ticket.id,
|
|
3925
|
+
`Re-accepted while held by ${shipLabel}; resuming there, not re-claiming. Setting a held ticket back to \`accepted\` is not needed \u2014 to move it to another ship, clear \`held_by_ship_id\`.`,
|
|
3926
|
+
seat
|
|
3927
|
+
);
|
|
3928
|
+
} catch {
|
|
3993
3929
|
}
|
|
3994
|
-
return null;
|
|
3995
3930
|
}
|
|
3996
|
-
function
|
|
3997
|
-
const
|
|
3998
|
-
|
|
3999
|
-
const candidates = [];
|
|
4000
|
-
for (const template of [templates.name, templates.push].filter(Boolean)) {
|
|
4001
|
-
const exact = render2(template);
|
|
4002
|
-
candidates.push(exact);
|
|
4003
|
-
if (num !== unpadded) candidates.push(exact.replace(num, unpadded));
|
|
4004
|
-
if (template.includes("{slug}")) {
|
|
4005
|
-
const prefix = render2(template.slice(0, template.indexOf("{slug}")));
|
|
4006
|
-
if (prefix) candidates.push(`${prefix}*`);
|
|
4007
|
-
}
|
|
4008
|
-
}
|
|
4009
|
-
candidates.push(`issue-${num}`);
|
|
4010
|
-
if (num !== unpadded) candidates.push(`issue-${unpadded}`);
|
|
4011
|
-
return [...new Set(candidates)];
|
|
3931
|
+
function planHoldReleases(tickets, contract) {
|
|
3932
|
+
const ends = [contract.statuses.handoff, contract.statuses.verified];
|
|
3933
|
+
return tickets.filter((t) => ends.includes(t.status) && ticketHolderShipId(t, contract) !== null);
|
|
4012
3934
|
}
|
|
4013
|
-
function
|
|
4014
|
-
|
|
4015
|
-
for (const
|
|
4016
|
-
|
|
4017
|
-
|
|
4018
|
-
|
|
3935
|
+
async function applyHoldReleases(writer, tickets, contract, emit) {
|
|
3936
|
+
let cleared = 0;
|
|
3937
|
+
for (const t of tickets) {
|
|
3938
|
+
try {
|
|
3939
|
+
await writer.updateTicket(t.id, { [contract.columns.heldBy]: null }, t.updated_at);
|
|
3940
|
+
cleared++;
|
|
3941
|
+
emit.emit(`released ship hold (${t.status})`, { ticket: t.issue_id, step: "sweep" });
|
|
3942
|
+
} catch (e) {
|
|
3943
|
+
emit.warn(`could not release ship hold: ${e.message}`, { ticket: t.issue_id, step: "sweep" });
|
|
3944
|
+
}
|
|
4019
3945
|
}
|
|
4020
|
-
return
|
|
3946
|
+
return cleared;
|
|
4021
3947
|
}
|
|
4022
|
-
function
|
|
4023
|
-
if (!
|
|
4024
|
-
|
|
3948
|
+
async function noteTakeover(tracker, result, seat, ships, myShipName, continuingFrom) {
|
|
3949
|
+
if (!result.tookOverFrom || !result.ticket) return;
|
|
3950
|
+
let sha = null;
|
|
3951
|
+
try {
|
|
3952
|
+
sha = continuingFrom(result.ticket);
|
|
3953
|
+
} catch {
|
|
4025
3954
|
}
|
|
4026
|
-
|
|
4027
|
-
|
|
3955
|
+
try {
|
|
3956
|
+
await tracker.postEvent(
|
|
3957
|
+
result.ticket.id,
|
|
3958
|
+
`Taken over from ${shipName(ships, result.tookOverFrom)} (not seen recently) by ${myShipName}; ` + (sha ? `continuing from \`${sha}\` on origin.` : "no branch commit was found on origin to continue from."),
|
|
3959
|
+
seat
|
|
3960
|
+
);
|
|
3961
|
+
} catch {
|
|
4028
3962
|
}
|
|
4029
|
-
gitOk(cwd, ["fetch", "--quiet", remote, `${branch}:${branch}`]);
|
|
4030
|
-
return true;
|
|
4031
|
-
}
|
|
4032
|
-
function deleteRemoteBranch(cwd, remote, branch) {
|
|
4033
|
-
if (!remoteBranchExists(cwd, remote, branch)) return true;
|
|
4034
|
-
return gitOk(cwd, ["push", remote, "--delete", branch]) !== null;
|
|
4035
3963
|
}
|
|
4036
|
-
function
|
|
4037
|
-
const
|
|
4038
|
-
|
|
3964
|
+
function originBranchSha(dir, t, rev) {
|
|
3965
|
+
const num = (t.issue_tag ?? t.issue_id).replace(/^\D+/, "");
|
|
3966
|
+
const names = [...new Set([(t.issue_tag ?? "").toLowerCase(), `issue-${num}`].filter(Boolean))];
|
|
3967
|
+
for (const n of names) {
|
|
3968
|
+
const sha = rev(dir, `origin/${n}`);
|
|
3969
|
+
if (sha) return sha.slice(0, 7);
|
|
3970
|
+
}
|
|
3971
|
+
return null;
|
|
4039
3972
|
}
|
|
4040
|
-
|
|
4041
|
-
|
|
4042
|
-
|
|
4043
|
-
|
|
4044
|
-
|
|
3973
|
+
function holdWarnings(tickets, ships, contract, humanHolds, now = Date.now()) {
|
|
3974
|
+
const inFlight = [contract.statuses.building, contract.statuses.verifying];
|
|
3975
|
+
const out = [];
|
|
3976
|
+
for (const t of tickets) {
|
|
3977
|
+
if (!inFlight.includes(t.status)) continue;
|
|
3978
|
+
if (t.assignee_id && humanHolds.has(t.assignee_id)) continue;
|
|
3979
|
+
const holder = ticketHolderShipId(t, contract);
|
|
3980
|
+
if (!holder) {
|
|
3981
|
+
out.push(`${t.issue_id} (${t.status}) has no ${contract.columns.heldBy} \u2014 any ship may take it`);
|
|
3982
|
+
} else if (!shipIsAlive(ships.find((s) => s.id === holder), now)) {
|
|
3983
|
+
out.push(`${t.issue_id} (${t.status}) is held by ${shipName(ships, holder)}, which has not been seen for ${SHIP_DEAD_AFTER_CYCLES}+ cycles`);
|
|
3984
|
+
}
|
|
3985
|
+
}
|
|
3986
|
+
return out;
|
|
4045
3987
|
}
|
|
4046
|
-
|
|
4047
|
-
|
|
4048
|
-
|
|
3988
|
+
|
|
3989
|
+
// src/select.ts
|
|
3990
|
+
var qaSlice = (tickets) => tickets.filter((t) => t.status === "fixed" || t.status === "qa");
|
|
3991
|
+
var buildingSlice = (tickets, role) => tickets.filter((t) => role === "design" ? t.needs_design === true : t.needs_design !== true);
|
|
3992
|
+
var triageSlice = (tickets, triageSeat) => triageSeat ? tickets.filter((t) => t.assignee_id === triageSeat) : [];
|
|
3993
|
+
function sliceFor(tickets, role) {
|
|
3994
|
+
if (role === "qa") return qaSlice(tickets);
|
|
3995
|
+
if (role === "triage") return triageSlice(tickets);
|
|
3996
|
+
if (role === "pair") return [];
|
|
3997
|
+
return buildingSlice(tickets, role);
|
|
4049
3998
|
}
|
|
4050
|
-
var
|
|
4051
|
-
function
|
|
4052
|
-
|
|
3999
|
+
var isHeld = (t, holds2) => !!t.assignee_id && holds2.has(t.assignee_id);
|
|
4000
|
+
function affinityContext(i) {
|
|
4001
|
+
return i.ships ? { myShipId: i.ships.myShipId, ships: i.ships.rows, contract: i.contract ?? DEFAULT_CONTRACT, now: i.ships.now } : null;
|
|
4053
4002
|
}
|
|
4054
|
-
|
|
4055
|
-
|
|
4056
|
-
|
|
4003
|
+
function withHoldCheck(affinity, i) {
|
|
4004
|
+
if (!affinity) return void 0;
|
|
4005
|
+
const a = affinityContext(i);
|
|
4006
|
+
return a ? { ...affinity, isHeldElsewhere: (t) => heldByOtherShip(t, a), isLiveHoldElsewhere: (t) => heldByLiveOtherShip(t, a) } : affinity;
|
|
4057
4007
|
}
|
|
4058
|
-
function
|
|
4059
|
-
|
|
4008
|
+
function notHeldByOtherShip(slice, i) {
|
|
4009
|
+
if (!i.ships) return slice;
|
|
4010
|
+
const a = { myShipId: i.ships.myShipId, ships: i.ships.rows, contract: i.contract ?? DEFAULT_CONTRACT, now: i.ships.now };
|
|
4011
|
+
return slice.filter((t) => !heldByOtherShip(t, a));
|
|
4060
4012
|
}
|
|
4061
|
-
function
|
|
4062
|
-
|
|
4013
|
+
function buildingRoleHasWork(role, i) {
|
|
4014
|
+
const me = i.seats[role];
|
|
4015
|
+
if (!me) return { hasWork: false, reason: "this ship does not crew that role" };
|
|
4016
|
+
const mine = notHeldByOtherShip(sliceFor(i.tickets, role), i);
|
|
4017
|
+
const startable = mine.filter(
|
|
4018
|
+
(t) => (t.status === "accepted" || t.status === "blocked") && !i.blocked.has(t.id) && !isHeld(t, i.holds)
|
|
4019
|
+
);
|
|
4020
|
+
const watched = mine.filter(
|
|
4021
|
+
(t) => (t.status === "in_progress" && (t.assignee_id === me || !t.assignee_id) || t.status === "needs_info") && !isHeld(t, i.holds)
|
|
4022
|
+
);
|
|
4023
|
+
const watchedIds = new Set(watched.map((t) => t.id));
|
|
4024
|
+
const newComments = i.comments.filter(
|
|
4025
|
+
(c) => c.created_at > i.watermark && c.team_member_id !== me && (c.kind ?? "comment") !== "event" && watchedIds.has(c.ticket_id)
|
|
4026
|
+
).length;
|
|
4027
|
+
const unassigned = mine.filter(
|
|
4028
|
+
(t) => t.status === "in_progress" && !t.assignee_id && !i.blocked.has(t.id)
|
|
4029
|
+
).length;
|
|
4030
|
+
const resumable = mine.filter(
|
|
4031
|
+
(t) => t.status === "in_progress" && t.assignee_id === me && !t.needs_planning && !t.needs_review && !isHeld(t, i.holds)
|
|
4032
|
+
).length;
|
|
4033
|
+
if (startable.length > 0 || newComments > 0 || unassigned > 0 || resumable > 0) {
|
|
4034
|
+
const parts = [];
|
|
4035
|
+
if (startable.length) parts.push(`${startable.length} startable`);
|
|
4036
|
+
if (newComments) parts.push(`${newComments} new comment(s)`);
|
|
4037
|
+
if (unassigned) parts.push(`${unassigned} unassigned in_progress`);
|
|
4038
|
+
if (resumable) parts.push(`${resumable} resumable in_progress`);
|
|
4039
|
+
return { hasWork: true, reason: parts.join(", ") };
|
|
4040
|
+
}
|
|
4041
|
+
return { hasWork: false, reason: "nothing startable and no new comments" };
|
|
4063
4042
|
}
|
|
4064
|
-
function
|
|
4065
|
-
|
|
4043
|
+
function qaRoleHasWork(i) {
|
|
4044
|
+
if (!i.seats.qa) return { hasWork: false, reason: "this ship does not crew that role" };
|
|
4045
|
+
const n = notHeldByOtherShip(qaSlice(i.tickets), i).filter((t) => !isHeld(t, i.holds)).length;
|
|
4046
|
+
return n > 0 ? { hasWork: true, reason: `${n} ticket(s) awaiting or in verification` } : { hasWork: false, reason: "nothing awaiting verification" };
|
|
4066
4047
|
}
|
|
4067
|
-
function
|
|
4068
|
-
|
|
4048
|
+
function triageRoleHasWork(i) {
|
|
4049
|
+
if (!i.seats.triage) return { hasWork: false, reason: "this ship does not crew that role" };
|
|
4050
|
+
const n = triageSlice(i.tickets, i.seats.triage).length;
|
|
4051
|
+
return n > 0 ? { hasWork: true, reason: `${n} report(s) assigned to triage` } : { hasWork: false, reason: "nothing assigned to triage" };
|
|
4069
4052
|
}
|
|
4070
|
-
function
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
return out.split("\n").map((l) => l.split(" ")[0]).filter(Boolean);
|
|
4053
|
+
function roleHasWork(role, i) {
|
|
4054
|
+
if (i.paused?.has(role)) return { hasWork: false, reason: "paused" };
|
|
4055
|
+
if (role === "qa") return qaRoleHasWork(i);
|
|
4056
|
+
if (role === "triage") return triageRoleHasWork(i);
|
|
4057
|
+
if (role === "pair") return { hasWork: false, reason: "pair is interactive, not polled" };
|
|
4058
|
+
return buildingRoleHasWork(role, i);
|
|
4077
4059
|
}
|
|
4078
|
-
function
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
4085
|
-
|
|
4086
|
-
|
|
4060
|
+
function roleCandidates(role, i) {
|
|
4061
|
+
if (role === "qa") return notHeldByOtherShip(qaSlice(i.tickets), i).filter((t) => !isHeld(t, i.holds));
|
|
4062
|
+
if (role === "triage") return triageSlice(i.tickets, i.seats.triage).filter((t) => !isHeld(t, i.holds));
|
|
4063
|
+
if (role === "pair") return [];
|
|
4064
|
+
const me = i.seats[role];
|
|
4065
|
+
if (!me) return [];
|
|
4066
|
+
return notHeldByOtherShip(sliceFor(i.tickets, role), i).filter(
|
|
4067
|
+
(t) => ((t.status === "accepted" || t.status === "blocked") && !i.blocked.has(t.id) || t.status === "in_progress" && (t.assignee_id === me || !t.assignee_id)) && !isHeld(t, i.holds)
|
|
4068
|
+
);
|
|
4087
4069
|
}
|
|
4088
|
-
function
|
|
4089
|
-
|
|
4090
|
-
if (candidates.length === 0) return null;
|
|
4091
|
-
const mb = gitOk(cwd, ["merge-base", base, branch]);
|
|
4092
|
-
const branchPatch = mb ? patchIdOf(cwd, mb, branch) : null;
|
|
4093
|
-
for (const sha of candidates) {
|
|
4094
|
-
if (gitOk(cwd, ["diff", "--quiet", branch, sha]) !== null) return sha;
|
|
4095
|
-
if (branchPatch && gitOk(cwd, ["rev-parse", "--verify", "-q", `${sha}^`]) !== null && patchIdOf(cwd, `${sha}^`, sha) === branchPatch) return sha;
|
|
4096
|
-
}
|
|
4097
|
-
return null;
|
|
4070
|
+
function claimStatuses(role, c) {
|
|
4071
|
+
return role === "qa" ? { approved: c.statuses.handoff, building: c.statuses.verifying } : { approved: c.statuses.approved, building: c.statuses.building };
|
|
4098
4072
|
}
|
|
4099
|
-
function
|
|
4100
|
-
|
|
4073
|
+
function rankedCandidates(role, i) {
|
|
4074
|
+
const c = i.contract ?? DEFAULT_CONTRACT;
|
|
4075
|
+
return [...roleCandidates(role, i)].sort((a, b) => rankScalar(a, c) - rankScalar(b, c));
|
|
4101
4076
|
}
|
|
4102
|
-
function
|
|
4103
|
-
|
|
4077
|
+
function roleTopRank(role, i) {
|
|
4078
|
+
if (role !== "qa" && role !== "triage" && !i.seats[role]) return NOTHING_ACTIONABLE;
|
|
4079
|
+
const candidates = roleCandidates(role, i);
|
|
4080
|
+
if (candidates.length === 0) return NOTHING_ACTIONABLE;
|
|
4081
|
+
const c = i.contract ?? DEFAULT_CONTRACT;
|
|
4082
|
+
return Math.min(...candidates.map((t) => rankScalar(t, c)));
|
|
4104
4083
|
}
|
|
4105
|
-
function
|
|
4106
|
-
const
|
|
4107
|
-
|
|
4084
|
+
function actionableSummary(i, pending) {
|
|
4085
|
+
const c = i.contract ?? DEFAULT_CONTRACT;
|
|
4086
|
+
const all = pending.flatMap((role) => roleCandidates(role, i));
|
|
4087
|
+
if (all.length === 0) return { count: 0 };
|
|
4088
|
+
const top = all.reduce((best, t) => rankScalar(t, c) < rankScalar(best, c) ? t : best);
|
|
4089
|
+
return { count: all.length, top };
|
|
4108
4090
|
}
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
|
|
4112
|
-
|
|
4113
|
-
|
|
4114
|
-
|
|
4115
|
-
|
|
4116
|
-
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
if (remoteBranchExists(cwd, remote, pushedBranch)) {
|
|
4120
|
-
return {
|
|
4121
|
-
state: "open",
|
|
4122
|
-
confidence: "heuristic",
|
|
4123
|
-
detail: `${remote}/${pushedBranch} still exists and ${key} is not on ${base}`
|
|
4124
|
-
};
|
|
4091
|
+
var FIXED_RANK = { qa: -2, triage: -1 };
|
|
4092
|
+
function selectRole(i) {
|
|
4093
|
+
const order = ["qa", "triage", "dev", "design"];
|
|
4094
|
+
const pending = [];
|
|
4095
|
+
const ranks = {};
|
|
4096
|
+
const reasons = {};
|
|
4097
|
+
for (const role of order) {
|
|
4098
|
+
const { hasWork, reason } = roleHasWork(role, i);
|
|
4099
|
+
reasons[role] = reason;
|
|
4100
|
+
if (hasWork) pending.push(role);
|
|
4125
4101
|
}
|
|
4126
|
-
return {
|
|
4127
|
-
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
4134
|
-
|
|
4135
|
-
const aliases = o.aliases ?? [];
|
|
4136
|
-
if (o.mergedHook) {
|
|
4137
|
-
const code = await o.mergedHook({
|
|
4138
|
-
CREW_TICKET: o.key,
|
|
4139
|
-
CREW_BRANCH: o.pushedBranch,
|
|
4140
|
-
CREW_BASE: base
|
|
4141
|
-
});
|
|
4142
|
-
if (code === 0) {
|
|
4143
|
-
const mergedAt = findKeyOnBase(o.cwd, [o.key, ...aliases], remote, base, o.since) ?? void 0;
|
|
4144
|
-
return {
|
|
4145
|
-
state: "merged",
|
|
4146
|
-
confidence: "definitive",
|
|
4147
|
-
mergedAt,
|
|
4148
|
-
detail: `the repo's merged hook says ${o.key} landed`
|
|
4149
|
-
};
|
|
4102
|
+
if (pending.length === 0) return { pending, selected: null, ranks, reasons };
|
|
4103
|
+
let selected = null;
|
|
4104
|
+
let best = Infinity;
|
|
4105
|
+
for (const role of pending) {
|
|
4106
|
+
const rank2 = FIXED_RANK[role] ?? roleTopRank(role, i);
|
|
4107
|
+
ranks[role] = rank2;
|
|
4108
|
+
if (rank2 < best) {
|
|
4109
|
+
best = rank2;
|
|
4110
|
+
selected = role;
|
|
4150
4111
|
}
|
|
4151
|
-
return remoteBranchExists(o.cwd, remote, o.pushedBranch) ? { state: "open", confidence: "definitive", detail: `the repo's merged hook says ${o.key} has not landed` } : { state: "abandoned", confidence: "definitive", detail: `${o.key} has not landed and ${remote}/${o.pushedBranch} is gone` };
|
|
4152
4112
|
}
|
|
4153
|
-
return
|
|
4154
|
-
}
|
|
4155
|
-
function upstreamOf(cwd, branch) {
|
|
4156
|
-
return gitOk(cwd, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", `${branch}@{upstream}`]);
|
|
4113
|
+
return { pending, selected, ranks, reasons };
|
|
4157
4114
|
}
|
|
4158
|
-
|
|
4159
|
-
|
|
4160
|
-
|
|
4161
|
-
|
|
4162
|
-
|
|
4163
|
-
|
|
4164
|
-
|
|
4165
|
-
|
|
4166
|
-
|
|
4167
|
-
|
|
4168
|
-
|
|
4169
|
-
|
|
4170
|
-
|
|
4171
|
-
|
|
4172
|
-
const [behindStr, aheadStr] = counts.split(/\s+/);
|
|
4173
|
-
const behind = Number.parseInt(behindStr ?? "0", 10);
|
|
4174
|
-
const ahead = Number.parseInt(aheadStr ?? "0", 10);
|
|
4175
|
-
const dirty = status(cwd).length > 0;
|
|
4176
|
-
let detail;
|
|
4177
|
-
if (behind === 0 && ahead === 0) detail = `level with ${upstream}`;
|
|
4178
|
-
else if (dirty) detail = `${behind} behind ${upstream}, but the worktree is dirty \u2014 not touching it`;
|
|
4179
|
-
else if (ahead > 0 && behind > 0) detail = `diverged from ${upstream} (${ahead} ahead, ${behind} behind) \u2014 needs a human`;
|
|
4180
|
-
else if (behind > 0) detail = `${behind} behind ${upstream} \u2014 fast-forwardable`;
|
|
4181
|
-
else detail = `${ahead} ahead of ${upstream}, nothing to take`;
|
|
4182
|
-
return {
|
|
4183
|
-
branch,
|
|
4184
|
-
upstream,
|
|
4185
|
-
ahead,
|
|
4186
|
-
behind,
|
|
4187
|
-
dirty,
|
|
4188
|
-
canFastForward: behind > 0 && ahead === 0 && !dirty,
|
|
4189
|
-
detail
|
|
4190
|
-
};
|
|
4115
|
+
|
|
4116
|
+
// src/epics.ts
|
|
4117
|
+
import { StaleWriteError as StaleWriteError3 } from "@tablation/client";
|
|
4118
|
+
var epicOf = (t, c) => {
|
|
4119
|
+
const v = t[c.columns.epic];
|
|
4120
|
+
return typeof v === "string" && v ? v : null;
|
|
4121
|
+
};
|
|
4122
|
+
var statusOf = (e, c) => {
|
|
4123
|
+
const v = e[c.epics.statusColumn];
|
|
4124
|
+
return typeof v === "string" && v ? v : null;
|
|
4125
|
+
};
|
|
4126
|
+
function epicLabel(e, c) {
|
|
4127
|
+
const v = e[c.epics.keyColumn];
|
|
4128
|
+
return typeof v === "string" && v ? v : e.id;
|
|
4191
4129
|
}
|
|
4192
|
-
function
|
|
4193
|
-
const s =
|
|
4194
|
-
|
|
4195
|
-
return
|
|
4130
|
+
function trackedEpics(epics, c) {
|
|
4131
|
+
const s = c.epics.statuses;
|
|
4132
|
+
const movable = /* @__PURE__ */ new Set([s.planned, s.building, s.done]);
|
|
4133
|
+
return epics.filter((e) => {
|
|
4134
|
+
const status2 = statusOf(e, c);
|
|
4135
|
+
return status2 !== null && movable.has(status2);
|
|
4136
|
+
});
|
|
4196
4137
|
}
|
|
4197
|
-
function
|
|
4198
|
-
|
|
4138
|
+
function workedStatuses(c) {
|
|
4139
|
+
const s = c.statuses;
|
|
4140
|
+
return new Set(
|
|
4141
|
+
[s.building, s.handoff, s.verifying, s.verified, s.reviewing].filter((v) => !!v)
|
|
4142
|
+
);
|
|
4199
4143
|
}
|
|
4200
|
-
function
|
|
4201
|
-
const
|
|
4202
|
-
return
|
|
4144
|
+
function epicsNeedingClosedCheck(epics, outstanding, c) {
|
|
4145
|
+
const hasOutstanding = new Set(outstanding.map((t) => epicOf(t, c)));
|
|
4146
|
+
return trackedEpics(epics, c).filter((e) => statusOf(e, c) !== c.epics.statuses.done && !hasOutstanding.has(e.id)).map((e) => e.id);
|
|
4203
4147
|
}
|
|
4204
|
-
function
|
|
4205
|
-
|
|
4206
|
-
|
|
4207
|
-
|
|
4208
|
-
|
|
4209
|
-
|
|
4210
|
-
|
|
4211
|
-
|
|
4212
|
-
|
|
4213
|
-
}
|
|
4214
|
-
if (!remoteBranchExists(cwd, remote, base)) {
|
|
4215
|
-
return { action: "not-applicable", detail: `${remote}/${base} does not exist` };
|
|
4148
|
+
function planEpicSync(epics, outstanding, closed, c) {
|
|
4149
|
+
const s = c.epics.statuses;
|
|
4150
|
+
const isClosed = new Set(closedStatuses(c));
|
|
4151
|
+
const worked = workedStatuses(c);
|
|
4152
|
+
const openByEpic = /* @__PURE__ */ new Map();
|
|
4153
|
+
for (const t of outstanding) {
|
|
4154
|
+
const id = epicOf(t, c);
|
|
4155
|
+
if (!id || isClosed.has(t.status)) continue;
|
|
4156
|
+
openByEpic.set(id, [...openByEpic.get(id) ?? [], t]);
|
|
4216
4157
|
}
|
|
4217
|
-
const
|
|
4218
|
-
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
|
|
4222
|
-
const named = () => {
|
|
4223
|
-
const subjects = (gitOk(cwd, ["log", "--format=%h %s", "-5", `${upstream}..${base}`]) ?? "").split("\n").filter(Boolean);
|
|
4224
|
-
return subjects.length ? `: ${subjects.join("; ")}${ahead > subjects.length ? "; \u2026" : ""}` : "";
|
|
4225
|
-
};
|
|
4226
|
-
if (ahead > 0 && behind > 0) {
|
|
4227
|
-
return {
|
|
4228
|
-
action: "diverged",
|
|
4229
|
-
ahead,
|
|
4230
|
-
behind,
|
|
4231
|
-
detail: `${base} has diverged from ${upstream} (${ahead} ahead, ${behind} behind)${named()}`
|
|
4232
|
-
};
|
|
4158
|
+
const closedCount = /* @__PURE__ */ new Map();
|
|
4159
|
+
for (const t of closed) {
|
|
4160
|
+
const id = epicOf(t, c);
|
|
4161
|
+
if (!id || !isClosed.has(t.status)) continue;
|
|
4162
|
+
closedCount.set(id, (closedCount.get(id) ?? 0) + 1);
|
|
4233
4163
|
}
|
|
4234
|
-
|
|
4235
|
-
|
|
4164
|
+
const steps = [];
|
|
4165
|
+
for (const epic of trackedEpics(epics, c)) {
|
|
4166
|
+
const from = statusOf(epic, c);
|
|
4167
|
+
const open = openByEpic.get(epic.id) ?? [];
|
|
4168
|
+
const label = epicLabel(epic, c);
|
|
4169
|
+
if (open.length === 0) {
|
|
4170
|
+
const n = closedCount.get(epic.id) ?? 0;
|
|
4171
|
+
if (from !== s.done && n > 0) {
|
|
4172
|
+
steps.push({
|
|
4173
|
+
action: "complete",
|
|
4174
|
+
epic,
|
|
4175
|
+
label,
|
|
4176
|
+
from,
|
|
4177
|
+
to: s.done,
|
|
4178
|
+
reason: n === 1 ? "its only ticket is closed" : `all ${n} tickets closed`
|
|
4179
|
+
});
|
|
4180
|
+
}
|
|
4181
|
+
continue;
|
|
4182
|
+
}
|
|
4183
|
+
const active = open.filter((t) => worked.has(t.status));
|
|
4184
|
+
if (active.length && from !== s.building) {
|
|
4185
|
+
steps.push({
|
|
4186
|
+
action: from === s.done ? "reopen" : "start",
|
|
4187
|
+
epic,
|
|
4188
|
+
label,
|
|
4189
|
+
from,
|
|
4190
|
+
to: s.building,
|
|
4191
|
+
reason: active.map((t) => `${t.issue_tag ?? t.issue_id} (${t.status})`).join(", ")
|
|
4192
|
+
});
|
|
4193
|
+
}
|
|
4236
4194
|
}
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
4195
|
+
return steps;
|
|
4196
|
+
}
|
|
4197
|
+
var describeEpicStep = (s) => `epic ${s.label}: ${s.from} -> ${s.to} (${s.reason})`;
|
|
4198
|
+
async function applyEpicSync(writer, steps, c, log) {
|
|
4199
|
+
let updated = 0;
|
|
4200
|
+
let contended = 0;
|
|
4201
|
+
let failed = 0;
|
|
4202
|
+
for (const step of steps) {
|
|
4203
|
+
const data = { epic: step.label, from: step.from, to: step.to };
|
|
4204
|
+
try {
|
|
4205
|
+
await writer.updateEpic(step.epic.id, { [c.epics.statusColumn]: step.to }, step.epic.updated_at);
|
|
4206
|
+
updated++;
|
|
4207
|
+
log.emit(describeEpicStep(step), { step: "sweep", data });
|
|
4208
|
+
} catch (e) {
|
|
4209
|
+
if (e instanceof StaleWriteError3) {
|
|
4210
|
+
contended++;
|
|
4211
|
+
log.emit(`epic ${step.label} changed since it was read \u2014 left for the next cycle`, { step: "sweep", data });
|
|
4212
|
+
continue;
|
|
4213
|
+
}
|
|
4214
|
+
failed++;
|
|
4215
|
+
log.warn(`could not move epic ${step.label} to ${step.to}: ${e.message}`, { step: "sweep", data });
|
|
4216
|
+
}
|
|
4240
4217
|
}
|
|
4241
|
-
|
|
4242
|
-
|
|
4218
|
+
return { updated, contended, failed };
|
|
4219
|
+
}
|
|
4220
|
+
|
|
4221
|
+
// src/attention.ts
|
|
4222
|
+
function attentionReasons(ticket, contract) {
|
|
4223
|
+
const reasons = [];
|
|
4224
|
+
if (ticket.status === contract.statuses.needsHuman) reasons.push("needs_info");
|
|
4225
|
+
if (ticket.needs_planning === true) reasons.push("needs_planning");
|
|
4226
|
+
if (ticket.needs_review === true) reasons.push("needs_review");
|
|
4227
|
+
return reasons;
|
|
4228
|
+
}
|
|
4229
|
+
function attentionTransitions(previous, current) {
|
|
4230
|
+
const out = [];
|
|
4231
|
+
for (const [ticketId, reasons] of Object.entries(current)) {
|
|
4232
|
+
const before = new Set(previous[ticketId] ?? []);
|
|
4233
|
+
const newReasons = reasons.filter((r) => !before.has(r));
|
|
4234
|
+
if (newReasons.length > 0) out.push({ ticketId, reasons: newReasons });
|
|
4243
4235
|
}
|
|
4244
|
-
return
|
|
4245
|
-
action: "fast-forwarded",
|
|
4246
|
-
behind,
|
|
4247
|
-
detail: `fast-forwarded ${base} to ${upstream} (${behind} commit(s) from elsewhere)`
|
|
4248
|
-
};
|
|
4236
|
+
return out;
|
|
4249
4237
|
}
|
|
4250
|
-
|
|
4251
|
-
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
4256
|
-
|
|
4257
|
-
|
|
4258
|
-
|
|
4238
|
+
|
|
4239
|
+
// src/digest.ts
|
|
4240
|
+
function displayKey2(t) {
|
|
4241
|
+
return t.issue_tag ?? t.issue_id ?? "?";
|
|
4242
|
+
}
|
|
4243
|
+
function author(c, i) {
|
|
4244
|
+
const { me, roster } = i;
|
|
4245
|
+
if (c.team_member_id === me) return "you";
|
|
4246
|
+
if (c.team_member_id === null || c.team_member_id === void 0) {
|
|
4247
|
+
const byIdentity = memberByIdentity(roster, c.created_by_id);
|
|
4248
|
+
if (byIdentity) return byIdentity.id === me ? "you" : crewLabel(byIdentity) ?? byIdentity.name;
|
|
4249
|
+
return `${c.reporter_name ?? "anon"} (no identity)`;
|
|
4259
4250
|
}
|
|
4260
|
-
|
|
4261
|
-
for (const b of stranded) gitOk(cwd, ["branch", "-q", "-D", b]);
|
|
4262
|
-
return stranded;
|
|
4251
|
+
return (crewLabel(roster.get(c.team_member_id)) ?? "someone off this ship") + shipSuffix(c, i);
|
|
4263
4252
|
}
|
|
4264
|
-
function
|
|
4265
|
-
|
|
4253
|
+
function shipSuffix(c, i) {
|
|
4254
|
+
if (!c.ship_id || c.ship_id === i.myShipId) return "";
|
|
4255
|
+
const name = i.ships?.find((s) => s.id === c.ship_id)?.name;
|
|
4256
|
+
return name ? ` (${name})` : "";
|
|
4266
4257
|
}
|
|
4267
|
-
function
|
|
4268
|
-
const
|
|
4269
|
-
|
|
4270
|
-
|
|
4271
|
-
|
|
4272
|
-
|
|
4273
|
-
|
|
4274
|
-
current = { path: line.slice("worktree ".length) };
|
|
4275
|
-
} else if (line.startsWith("branch ")) {
|
|
4276
|
-
current.branch = line.slice("branch refs/heads/".length);
|
|
4277
|
-
} else if (line === "detached") {
|
|
4278
|
-
current.branch = null;
|
|
4279
|
-
} else if (line.startsWith("prunable ")) {
|
|
4280
|
-
current.prunable = true;
|
|
4281
|
-
}
|
|
4258
|
+
function filedBy(t, i) {
|
|
4259
|
+
const member = (t.filed_by_id ? i.roster.get(t.filed_by_id) : void 0) ?? memberByIdentity(i.roster, t.created_by_id);
|
|
4260
|
+
if (member) {
|
|
4261
|
+
if (member.id === i.me) return "you";
|
|
4262
|
+
const label = crewLabel(member) ?? member.name;
|
|
4263
|
+
const ship = t.ship_id && t.ship_id !== i.myShipId ? i.ships?.find((s) => s.id === t.ship_id)?.name : void 0;
|
|
4264
|
+
return ship ? `${label} (${ship})` : label;
|
|
4282
4265
|
}
|
|
4283
|
-
|
|
4284
|
-
return list;
|
|
4266
|
+
return t.reporter_name ? `${t.reporter_name} (unverified)` : "\u2014";
|
|
4285
4267
|
}
|
|
4286
|
-
function
|
|
4287
|
-
|
|
4268
|
+
function lastComment(i, ticketId) {
|
|
4269
|
+
const forTicket = i.comments.filter((c) => c.ticket_id === ticketId).sort((a, b) => a.created_at < b.created_at ? -1 : a.created_at > b.created_at ? 1 : 0);
|
|
4270
|
+
const last = forTicket[forTicket.length - 1];
|
|
4271
|
+
if (!last) return "\u2014";
|
|
4272
|
+
return `${last.created_at.slice(0, 16)}Z ${author(last, i)}`;
|
|
4288
4273
|
}
|
|
4289
|
-
function
|
|
4290
|
-
|
|
4291
|
-
|
|
4292
|
-
|
|
4293
|
-
`${dir} does not exist and this repo has no discovered remote to clone from \u2014 re-run \`crew connect\`, or check out the repository yourself and point \`repos:\` at it`
|
|
4294
|
-
);
|
|
4295
|
-
}
|
|
4296
|
-
mkdirSync4(dirname4(dir), { recursive: true });
|
|
4297
|
-
try {
|
|
4298
|
-
execFileSync("git", ["clone", cloneUrlFor(remote), dir], { stdio: ["ignore", "pipe", "pipe"] });
|
|
4299
|
-
} catch (e) {
|
|
4300
|
-
const err = e;
|
|
4301
|
-
const why = [err.stderr, err.stdout].map((x) => (x ?? "").trim()).filter(Boolean).join(" / ");
|
|
4302
|
-
throw new GitError(`git clone ${cloneUrlFor(remote)} ${dir}: ${why || err.message.trim()}`);
|
|
4303
|
-
}
|
|
4304
|
-
return true;
|
|
4274
|
+
function newFromOthers(i, ticketId) {
|
|
4275
|
+
return i.comments.filter(
|
|
4276
|
+
(c) => c.ticket_id === ticketId && c.team_member_id !== i.me && c.created_at > i.watermark && (c.kind ?? "comment") !== "event"
|
|
4277
|
+
).length;
|
|
4305
4278
|
}
|
|
4306
|
-
|
|
4307
|
-
|
|
4308
|
-
return `${dir}.sync.lock`;
|
|
4279
|
+
function who(t, i) {
|
|
4280
|
+
return whoAssigned(t, i) + heldBySuffix(t, i);
|
|
4309
4281
|
}
|
|
4310
|
-
function
|
|
4311
|
-
|
|
4282
|
+
function heldBySuffix(t, i) {
|
|
4283
|
+
if (!t.held_by_ship_id || t.held_by_ship_id === i.myShipId) return "";
|
|
4284
|
+
const name = i.ships?.find((s) => s.id === t.held_by_ship_id)?.name?.trim();
|
|
4285
|
+
return ` (held by ${name || "an unknown ship"})`;
|
|
4312
4286
|
}
|
|
4313
|
-
function
|
|
4314
|
-
|
|
4315
|
-
|
|
4316
|
-
const
|
|
4317
|
-
|
|
4318
|
-
|
|
4319
|
-
const fd = openSync(lockPath, "wx");
|
|
4320
|
-
writeSync(fd, `${process.pid}
|
|
4321
|
-
`);
|
|
4322
|
-
closeSync(fd);
|
|
4323
|
-
return () => {
|
|
4324
|
-
try {
|
|
4325
|
-
unlinkSync2(lockPath);
|
|
4326
|
-
} catch {
|
|
4327
|
-
}
|
|
4328
|
-
};
|
|
4329
|
-
} catch (e) {
|
|
4330
|
-
const err = e;
|
|
4331
|
-
if (err.code !== "EEXIST") throw err;
|
|
4332
|
-
try {
|
|
4333
|
-
if (Date.now() - statSync3(lockPath).mtimeMs > CHECKOUT_LOCK_STALE_MS) {
|
|
4334
|
-
try {
|
|
4335
|
-
unlinkSync2(lockPath);
|
|
4336
|
-
} catch {
|
|
4337
|
-
}
|
|
4338
|
-
continue;
|
|
4339
|
-
}
|
|
4340
|
-
} catch {
|
|
4341
|
-
}
|
|
4342
|
-
if (Date.now() > deadline) {
|
|
4343
|
-
throw new GitError(`timed out waiting for the sync lock on ${dir} (held by another sync)`);
|
|
4344
|
-
}
|
|
4345
|
-
sleepSync(50);
|
|
4346
|
-
}
|
|
4347
|
-
}
|
|
4287
|
+
function whoAssigned(t, i) {
|
|
4288
|
+
if (!t.assignee_id) return "unassigned";
|
|
4289
|
+
if (t.assignee_id === i.me) return "you";
|
|
4290
|
+
const label = crewLabel(i.roster.get(t.assignee_id));
|
|
4291
|
+
if (label === null) return "someone off this ship";
|
|
4292
|
+
return isHold(i.roster, t.assignee_id) ? `**${label} \u2014 HOLD**` : label;
|
|
4348
4293
|
}
|
|
4349
|
-
function
|
|
4350
|
-
const
|
|
4351
|
-
|
|
4352
|
-
|
|
4353
|
-
|
|
4354
|
-
|
|
4355
|
-
|
|
4356
|
-
|
|
4357
|
-
|
|
4358
|
-
|
|
4359
|
-
|
|
4294
|
+
function blockers(t, i) {
|
|
4295
|
+
const list = (t.blocked_by ?? []).map((id) => {
|
|
4296
|
+
const info = i.blockerInfo[id];
|
|
4297
|
+
return `${info ? displayKey2(info) : "?"} (${info?.status ?? "unknown"})`;
|
|
4298
|
+
});
|
|
4299
|
+
return list.length === 0 ? "\u2014" : list.join(", ");
|
|
4300
|
+
}
|
|
4301
|
+
var sorted = (ts) => [...ts].sort(compareRank);
|
|
4302
|
+
var stream = (segments) => `${segments.join("\n")}
|
|
4303
|
+
`;
|
|
4304
|
+
function table(ts, i, header, row) {
|
|
4305
|
+
if (ts.length === 0) return "\n_None._\n";
|
|
4306
|
+
return `
|
|
4307
|
+
${header}
|
|
4308
|
+
${sorted(ts).map(row).join("\n")}
|
|
4309
|
+
`;
|
|
4310
|
+
}
|
|
4311
|
+
function buildingDigest(i) {
|
|
4312
|
+
const header = "| ticket | repo | branch | worktree | status | assignee | filed by | sev | pri | eff | updated | last comment | new since last poll |\n|---|---|---|---|---|---|---|---|---|---|---|---|---|";
|
|
4313
|
+
const row = (t) => {
|
|
4314
|
+
const n = newFromOthers(i, t.id);
|
|
4315
|
+
const branch = i.branchFor ? i.branchFor(t) : "";
|
|
4316
|
+
const worktree = i.worktreeFor ? i.worktreeFor(t) : "";
|
|
4317
|
+
const dir = i.dirFor ? i.dirFor(t) : null;
|
|
4318
|
+
const repo = i.dirFor ? dir ?? "**NO CHECKOUT**" : "";
|
|
4319
|
+
return `| ${displayKey2(t)} | ${repo} | ${branch} | ${worktree} | ${t.status} | ${who(t, i)} | ${filedBy(t, i)} | ${t.severity ?? "\u2014"} | ${t.priority ?? "\u2014"} | p${effectivePriority(t)} | ${t.updated_at.slice(0, 16)}Z | ${lastComment(i, t.id)} | ${n > 0 ? `**${n} new**` : "\u2014"} |`;
|
|
4320
|
+
};
|
|
4321
|
+
const blockedRow = (t) => `| ${displayKey2(t)} | ${t.status} | ${who(t, i)} | p${effectivePriority(t)} | ${blockers(t, i)} |`;
|
|
4322
|
+
const blockedTable = (ts) => ts.length === 0 ? "\n_None._\n" : `
|
|
4323
|
+
| ticket | status | assignee | eff | blocked by |
|
|
4324
|
+
|---|---|---|---|---|
|
|
4325
|
+
${sorted(ts).map(blockedRow).join("\n")}
|
|
4326
|
+
`;
|
|
4327
|
+
const isBlocked = (t) => i.blocked.has(t.id);
|
|
4328
|
+
const step1 = i.tickets.filter(
|
|
4329
|
+
(t) => t.status === "in_progress" && (t.assignee_id === i.me || !t.assignee_id) || t.status === "needs_info"
|
|
4330
|
+
);
|
|
4331
|
+
const step2 = i.tickets.filter(
|
|
4332
|
+
(t) => (t.status === "accepted" || t.status === "blocked") && !isBlocked(t)
|
|
4333
|
+
);
|
|
4334
|
+
return stream([
|
|
4335
|
+
"## Current queue \u2014 built for you by the poll\n",
|
|
4336
|
+
"\nAlready filtered to your lane, and already ordered by the Step 2 rule.\nTicket bodies are deliberately omitted: fetch the full record of only the\nticket you actually pick up. **Do not re-fetch the whole tracker.** This\ndigest comes from the same API call the poll just made, moments ago.\n",
|
|
4337
|
+
'\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',
|
|
4338
|
+
"\n**`repo` is the checkout a ticket's work happens in** \u2014 an area spans\nseveral repositories, so cut the worktree beside THAT directory, not beside\nwhichever one you started in. A ticket marked **NO CHECKOUT** is not yours:\nthis ship has no clone of its repository, and another ship may serve it.\n\n**Use the `branch` and `worktree` columns verbatim.** They are that\nrepository's own naming convention, rendered for THIS ticket \u2014 cut the\nworktree at `<repo>/../<worktree>`, on branch `<branch>`. Do not derive\neither yourself, and prefer these over the Environment section's own worked\nexample if the two ever disagree \u2014 that example is illustrative only,\nrendered before any ticket is picked.\n",
|
|
4339
|
+
"\n### Step 1 \u2014 open tickets that may be yours to act on\n",
|
|
4340
|
+
"\n`fixed` tickets are deliberately absent: they belong to the QA lane.\n",
|
|
4341
|
+
table(step1, i, header, row),
|
|
4342
|
+
"\n### Step 2 \u2014 accepted tickets, in pick order\n",
|
|
4343
|
+
"\nWork the first one you are not required to skip. Tickets waiting on an\nunresolved dependency are already out of this table \u2014 see Blocked below.\n",
|
|
4344
|
+
table(step2, i, header, row),
|
|
4345
|
+
"\n### Blocked \u2014 waiting on a dependency, not yours to start\n",
|
|
4346
|
+
'\nListed rather than hidden so "nothing to do" stays distinguishable from\n"everything is parked". The loop parks and restores these itself, on every\npoll: an approved ticket whose `Blocked by` entries are unresolved moves to\n`blocked`, and back to `accepted` once the last one resolves. **Do not pick\none up, do not set or clear `blocked` by hand, and do not "unblock" one by\nediting its `Blocked by` field** \u2014 if a blocker looks wrong, say so in your\nrun summary. A blocker counts as resolved at `verified`, `closed_deployed`,\n`closed_wont_fix` or `closed_duplicate`; `fixed` is still an unmerged branch\nawaiting QA, so it does not count.\n',
|
|
4347
|
+
blockedTable(i.tickets.filter(isBlocked))
|
|
4348
|
+
]);
|
|
4349
|
+
}
|
|
4350
|
+
function qaDigest(i) {
|
|
4351
|
+
const header = "| ticket | repo | status | built by | assignee | filed by | sev | pri | eff | branch | worktree | updated | last comment | new since last poll |\n|---|---|---|---|---|---|---|---|---|---|---|---|---|---|";
|
|
4352
|
+
const branchCell = (t) => {
|
|
4353
|
+
if (i.dirFor && i.dirFor(t) === null) return "\u2014";
|
|
4354
|
+
if (!i.existingBranchFor) return "";
|
|
4355
|
+
return i.existingBranchFor(t) ?? "**MISSING**";
|
|
4356
|
+
};
|
|
4357
|
+
const worktreeCell = (t) => {
|
|
4358
|
+
if (i.dirFor && i.dirFor(t) === null) return "\u2014";
|
|
4359
|
+
return i.worktreeFor ? i.worktreeFor(t) : "";
|
|
4360
|
+
};
|
|
4361
|
+
const builtBy = (t) => t.needs_design === true ? "design" : "dev";
|
|
4362
|
+
const row = (t) => {
|
|
4363
|
+
const n = newFromOthers(i, t.id);
|
|
4364
|
+
const dir = i.dirFor ? i.dirFor(t) : null;
|
|
4365
|
+
const repo = i.dirFor ? dir ?? "**NO CHECKOUT**" : "";
|
|
4366
|
+
return `| ${displayKey2(t)} | ${repo} | ${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"} |`;
|
|
4367
|
+
};
|
|
4368
|
+
return stream([
|
|
4369
|
+
"## Current queue \u2014 built for you by the poll\n",
|
|
4370
|
+
'\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',
|
|
4371
|
+
'\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',
|
|
4372
|
+
"\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",
|
|
4373
|
+
"\n### Still in verification \u2014 yours, unfinished (take these first)\n",
|
|
4374
|
+
table(i.tickets.filter((t) => t.status === "qa"), i, header, row),
|
|
4375
|
+
"\n### Awaiting verification, in pick order\n",
|
|
4376
|
+
table(i.tickets.filter((t) => t.status === "fixed"), i, header, row)
|
|
4377
|
+
]);
|
|
4360
4378
|
}
|
|
4361
4379
|
|
|
4362
4380
|
// src/ticket-branch.ts
|
|
@@ -6608,6 +6626,30 @@ async function mergeAndRelease(o, decision, fresh, releaseBranch) {
|
|
|
6608
6626
|
}
|
|
6609
6627
|
}
|
|
6610
6628
|
}
|
|
6629
|
+
if (remoteConfigured(o.cwd, o.repo.branch.remote)) {
|
|
6630
|
+
if (o.dryRun) {
|
|
6631
|
+
o.emit.emit(`would push ${o.repo.branch.base} to ${o.repo.branch.remote}`);
|
|
6632
|
+
} else {
|
|
6633
|
+
try {
|
|
6634
|
+
pushBranch(o.cwd, o.repo.branch.remote, releaseBranch ?? o.repo.branch.base, o.repo.branch.base);
|
|
6635
|
+
o.emit.emit(`pushed ${o.repo.branch.base} to ${o.repo.branch.remote}`);
|
|
6636
|
+
} catch (e) {
|
|
6637
|
+
o.emit.error(
|
|
6638
|
+
`failed to push ${o.repo.branch.base} to ${o.repo.branch.remote} \u2014 ${e.message}; not deploying`
|
|
6639
|
+
);
|
|
6640
|
+
return {
|
|
6641
|
+
merged,
|
|
6642
|
+
conflicts,
|
|
6643
|
+
unbuildable,
|
|
6644
|
+
version,
|
|
6645
|
+
deployed: false,
|
|
6646
|
+
integrated: false,
|
|
6647
|
+
decision,
|
|
6648
|
+
stopped: "base branch push failed"
|
|
6649
|
+
};
|
|
6650
|
+
}
|
|
6651
|
+
}
|
|
6652
|
+
}
|
|
6611
6653
|
let deployed = false;
|
|
6612
6654
|
let integrated = false;
|
|
6613
6655
|
if (o.repo.release.mode === "integrate") {
|
|
@@ -6622,7 +6664,9 @@ async function mergeAndRelease(o, decision, fresh, releaseBranch) {
|
|
|
6622
6664
|
r = await hook(o, "deploy");
|
|
6623
6665
|
}
|
|
6624
6666
|
if (r && r.code !== 0) {
|
|
6625
|
-
o.emit.error(
|
|
6667
|
+
o.emit.error(
|
|
6668
|
+
`deploy FAILED (exit ${r.code}) \u2014 the target may be partially deployed` + (remoteConfigured(o.cwd, o.repo.branch.remote) ? `; ${version ?? "this release"} is already merged to ${o.repo.branch.remote}/${o.repo.branch.base} but NOT live \u2014 run \`crew deploy\`, do not re-release` : "")
|
|
6669
|
+
);
|
|
6626
6670
|
o.state?.noteDeployFailed(headSha(o.cwd));
|
|
6627
6671
|
return { merged, conflicts, unbuildable, version, deployed: false, stopped: "deploy failed", decision, hookFailure: hookFailure(o, "deploy", r.output) };
|
|
6628
6672
|
}
|
|
@@ -6632,30 +6676,6 @@ async function mergeAndRelease(o, decision, fresh, releaseBranch) {
|
|
|
6632
6676
|
} else if (o.repo.release.mode !== "local") {
|
|
6633
6677
|
o.emit.emit(`release.mode is ${o.repo.release.mode} \u2014 CI takes it from here`);
|
|
6634
6678
|
}
|
|
6635
|
-
if (remoteConfigured(o.cwd, o.repo.branch.remote)) {
|
|
6636
|
-
if (o.dryRun) {
|
|
6637
|
-
o.emit.emit(`would push ${o.repo.branch.base} to ${o.repo.branch.remote}`);
|
|
6638
|
-
} else {
|
|
6639
|
-
try {
|
|
6640
|
-
pushBranch(o.cwd, o.repo.branch.remote, releaseBranch ?? o.repo.branch.base, o.repo.branch.base);
|
|
6641
|
-
o.emit.emit(`pushed ${o.repo.branch.base} to ${o.repo.branch.remote}`);
|
|
6642
|
-
} catch (e) {
|
|
6643
|
-
o.emit.error(
|
|
6644
|
-
`failed to push ${o.repo.branch.base} to ${o.repo.branch.remote} \u2014 ${e.message}`
|
|
6645
|
-
);
|
|
6646
|
-
return {
|
|
6647
|
-
merged,
|
|
6648
|
-
conflicts,
|
|
6649
|
-
unbuildable,
|
|
6650
|
-
version,
|
|
6651
|
-
deployed,
|
|
6652
|
-
integrated,
|
|
6653
|
-
decision,
|
|
6654
|
-
stopped: "base branch push failed"
|
|
6655
|
-
};
|
|
6656
|
-
}
|
|
6657
|
-
}
|
|
6658
|
-
}
|
|
6659
6679
|
let tag;
|
|
6660
6680
|
if (!version && o.dryRun && o.repo.hooks.bump && o.repo.release.tag) {
|
|
6661
6681
|
o.emit.emit(`would then tag using ${o.repo.release.tag}, with whatever version the hook printed`);
|
|
@@ -11889,6 +11909,9 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
11889
11909
|
let baseUnsafe = 0;
|
|
11890
11910
|
for (const r of await resolvedRepos(route)) {
|
|
11891
11911
|
const base = refreshBaseBranch(r.dir, r.config.branch.remote, r.config.branch.base, dryRun);
|
|
11912
|
+
const att = dryRun ? null : baseAttention(r.name, base, r.config.branch.remote, r.config.branch.base);
|
|
11913
|
+
if (att && "raise" in att) await raiseShipAttention({ state, emit, route, ship: cfg.ship }, att.raise);
|
|
11914
|
+
else if (att) clearShipAttention(state, att.clear);
|
|
11892
11915
|
if (base.action !== "not-applicable") {
|
|
11893
11916
|
baseChecked++;
|
|
11894
11917
|
if (base.action === "fetch-failed") emit.warn(base.detail);
|