@clipboard-health/groundcrew 4.0.1 → 4.0.2
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/README.md +51 -72
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +11 -25
- package/dist/commands/doctor.d.ts +1 -6
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +24 -37
- package/dist/commands/interruptWorkspace.js +1 -1
- package/dist/commands/status.d.ts +7 -0
- package/dist/commands/status.d.ts.map +1 -0
- package/dist/commands/status.js +178 -0
- package/dist/index.d.ts +1 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/lib/util.d.ts +3 -2
- package/dist/lib/util.d.ts.map +1 -1
- package/dist/lib/util.js +18 -2
- package/package.json +1 -1
- package/dist/commands/ticketCheck.d.ts +0 -22
- package/dist/commands/ticketCheck.d.ts.map +0 -1
- package/dist/commands/ticketCheck.js +0 -23
- package/dist/commands/ticketDoctor.d.ts +0 -223
- package/dist/commands/ticketDoctor.d.ts.map +0 -1
- package/dist/commands/ticketDoctor.js +0 -1134
|
@@ -1,1134 +0,0 @@
|
|
|
1
|
-
// src/commands/ticketDoctor.ts
|
|
2
|
-
//
|
|
3
|
-
// `crew doctor --ticket <TICKET>` — single per-ticket diagnostic that covers
|
|
4
|
-
// both the pre-dispatch question ("will this dispatch on the next tick?") and
|
|
5
|
-
// the post-dispatch question ("what's already happened and what's left to
|
|
6
|
-
// do?"). Verdict precedence starts with PR outcomes, then handles run-state
|
|
7
|
-
// exceptions before ordinary local recovery:
|
|
8
|
-
//
|
|
9
|
-
// pr-open > pr-merged
|
|
10
|
-
// > failed-launch
|
|
11
|
-
// > interrupted (unless concrete recoverable git work exists)
|
|
12
|
-
// > in-flight > recoverable
|
|
13
|
-
// > unresolvable > ineligible > would-dispatch > lost
|
|
14
|
-
//
|
|
15
|
-
// When a post-dispatch verdict fires, the Resolution and Eligibility sections
|
|
16
|
-
// are skipped — they describe pre-dispatch state that no longer matters.
|
|
17
|
-
import { existsSync } from "node:fs";
|
|
18
|
-
import { join } from "node:path";
|
|
19
|
-
import { fetchBlockersForTicket, fetchInProgressIssueCount, fetchRawLinearIssue, isTerminalStateType, resolveModelFor, resolveRepositoryFor, } from "../lib/boardSource.js";
|
|
20
|
-
import { runCommandAsync } from "../lib/commandRunner.js";
|
|
21
|
-
import { resolveDefaultBranch } from "../lib/defaultBranch.js";
|
|
22
|
-
import { AGENT_ANY_MODEL, loadConfig } from "../lib/config.js";
|
|
23
|
-
import { which } from "../lib/host.js";
|
|
24
|
-
import { readRunState } from "../lib/runState.js";
|
|
25
|
-
import { getUsageByModel } from "../lib/usage.js";
|
|
26
|
-
import { getLinearClient, lazyLinearClient, writeOutput } from "../lib/util.js";
|
|
27
|
-
import { workspaces } from "../lib/workspaces.js";
|
|
28
|
-
import { worktrees } from "../lib/worktrees.js";
|
|
29
|
-
import { classifyBlockers, classifyUsageExhaustion, pickBestModel, } from "./eligibility.js";
|
|
30
|
-
import { renderTicketCheckResult } from "./ticketCheck.js";
|
|
31
|
-
/**
|
|
32
|
-
* Placeholder Linear state name used when `--no-linear` suppresses the Linear
|
|
33
|
-
* probe. The verdict decision reads `linear.kind` only — this name is never
|
|
34
|
-
* surfaced to the user.
|
|
35
|
-
*/
|
|
36
|
-
const LINEAR_SKIPPED_STATE_NAME = "(linear skipped)";
|
|
37
|
-
// ───────── post-dispatch verdict (PR / in-flight / recoverable) ─────────
|
|
38
|
-
function verdictInFlight(input) {
|
|
39
|
-
if (input.linear.kind !== "non-terminal") {
|
|
40
|
-
return undefined;
|
|
41
|
-
}
|
|
42
|
-
const worktreePresent = input.worktree.kind === "present-clean" ||
|
|
43
|
-
input.worktree.kind === "present-dirty" ||
|
|
44
|
-
input.worktree.kind === "present-unknown-dirtiness";
|
|
45
|
-
if (!worktreePresent) {
|
|
46
|
-
return undefined;
|
|
47
|
-
}
|
|
48
|
-
/* v8 ignore next 3 @preserve -- callers always pass workspaceName or worktreeDir; defensive guard for the rare both-missing path */
|
|
49
|
-
const where = input.workspaceName === undefined
|
|
50
|
-
? `worktree at ${input.worktreeDir ?? "<unknown>"}`
|
|
51
|
-
: `workspace "${input.workspaceName}"`;
|
|
52
|
-
return { kind: "in-flight", reason: `ticket is mid-flight in ${where}` };
|
|
53
|
-
}
|
|
54
|
-
function verdictRecoverable(input) {
|
|
55
|
-
if (input.worktree.kind === "present-dirty") {
|
|
56
|
-
/* v8 ignore next @preserve -- a present worktree always has a worktreeDir; nullish guard is defensive */
|
|
57
|
-
const where = input.worktreeDir ?? "<worktree>";
|
|
58
|
-
return {
|
|
59
|
-
kind: "recoverable",
|
|
60
|
-
reason: `dirty worktree (${input.worktree.modified} modified, ${input.worktree.untracked} untracked)`,
|
|
61
|
-
nextStep: `commit or stash in ${where}, then re-run \`crew doctor --ticket ${input.branch}\``,
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
const worktreeIsClean = input.worktree.kind === "present-clean" || input.worktree.kind === "present-unknown-dirtiness";
|
|
65
|
-
if (worktreeIsClean &&
|
|
66
|
-
input.localBranch.kind === "present" &&
|
|
67
|
-
input.remoteBranch.kind === "absent") {
|
|
68
|
-
/* v8 ignore next @preserve -- a present worktree always has a worktreeDir; nullish guard is defensive */
|
|
69
|
-
const where = input.worktreeDir ?? "<worktree>";
|
|
70
|
-
return {
|
|
71
|
-
kind: "recoverable",
|
|
72
|
-
reason: `clean worktree with un-pushed local branch`,
|
|
73
|
-
nextStep: `cd ${where}; git push -u origin ${input.branch}; gh pr create`,
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
if (input.worktree.kind === "absent" &&
|
|
77
|
-
input.remoteBranch.kind === "present" &&
|
|
78
|
-
input.pullRequest.kind === "absent") {
|
|
79
|
-
return {
|
|
80
|
-
kind: "recoverable",
|
|
81
|
-
reason: `remote branch exists without a PR`,
|
|
82
|
-
nextStep: `gh pr create --head ${input.branch}`,
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
if (input.worktree.kind === "absent" && input.localBranch.kind === "present") {
|
|
86
|
-
return {
|
|
87
|
-
kind: "recoverable",
|
|
88
|
-
reason: `stranded local branch (no worktree)`,
|
|
89
|
-
nextStep: `push the branch or delete it: \`git branch -D ${input.branch}\``,
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
return undefined;
|
|
93
|
-
}
|
|
94
|
-
/**
|
|
95
|
-
* Returns a post-dispatch verdict if the probe bundle matches one of the
|
|
96
|
-
* "ticket has moved past dispatch" cases. Returns `undefined` otherwise,
|
|
97
|
-
* signalling that the caller should fall through to the pre-dispatch path.
|
|
98
|
-
*
|
|
99
|
-
* Precedence: PR verdicts always win. Failed launches report before ordinary
|
|
100
|
-
* local recovery. Interrupted runs report concrete recoverable git work first
|
|
101
|
-
* when it exists, then fall back to `interrupted`. Ordinary post-dispatch cases
|
|
102
|
-
* report in-flight before recoverable. Inside `recoverable`, dirty worktree
|
|
103
|
-
* beats clean-with-un-pushed-local beats remote-only beats stranded local.
|
|
104
|
-
*/
|
|
105
|
-
export function decidePostDispatchVerdict(input) {
|
|
106
|
-
if (input.pullRequest.kind === "open") {
|
|
107
|
-
return { kind: "pr-open", number: input.pullRequest.number, url: input.pullRequest.url };
|
|
108
|
-
}
|
|
109
|
-
if (input.pullRequest.kind === "merged") {
|
|
110
|
-
return { kind: "pr-merged", number: input.pullRequest.number, url: input.pullRequest.url };
|
|
111
|
-
}
|
|
112
|
-
const recoverable = verdictRecoverable(input);
|
|
113
|
-
if (input.runState?.state === "interrupted") {
|
|
114
|
-
if (recoverable !== undefined) {
|
|
115
|
-
return recoverable;
|
|
116
|
-
}
|
|
117
|
-
const detail = input.runState.reason ?? input.runState.detail ?? "workspace stopped";
|
|
118
|
-
return {
|
|
119
|
-
kind: "interrupted",
|
|
120
|
-
reason: detail,
|
|
121
|
-
nextStep: `run \`crew resume ${input.runState.ticket}\` or inspect ${input.runState.worktreeDir}`,
|
|
122
|
-
};
|
|
123
|
-
}
|
|
124
|
-
if (input.runState?.state === "failed-to-launch") {
|
|
125
|
-
const detail = input.runState.detail ?? "workspace launch failed";
|
|
126
|
-
return {
|
|
127
|
-
kind: "failed-launch",
|
|
128
|
-
reason: detail,
|
|
129
|
-
nextStep: `fix the launch failure, then run \`crew resume ${input.runState.ticket}\` or \`crew cleanup ${input.runState.ticket}\``,
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
return verdictInFlight(input) ?? recoverable;
|
|
133
|
-
}
|
|
134
|
-
function emptySkipReasons() {
|
|
135
|
-
return {
|
|
136
|
-
resolution: "",
|
|
137
|
-
eligibility: "",
|
|
138
|
-
worktree: "",
|
|
139
|
-
runState: "",
|
|
140
|
-
workspace: "",
|
|
141
|
-
localBranch: "",
|
|
142
|
-
remoteBranch: "",
|
|
143
|
-
pullRequest: "",
|
|
144
|
-
};
|
|
145
|
-
}
|
|
146
|
-
function buildModelChecks(raw, config) {
|
|
147
|
-
const modelResolution = resolveModelFor({ labels: raw.labels, config });
|
|
148
|
-
const checks = [];
|
|
149
|
-
switch (modelResolution.kind) {
|
|
150
|
-
case "no-label": {
|
|
151
|
-
checks.push({
|
|
152
|
-
name: "Has agent-* label",
|
|
153
|
-
status: "fail",
|
|
154
|
-
detail: "no agent-* label on this ticket",
|
|
155
|
-
failureSummary: "ticket has no agent-* label",
|
|
156
|
-
});
|
|
157
|
-
checks.push({ name: "Model resolves from agent-* label", status: "skipped" });
|
|
158
|
-
break;
|
|
159
|
-
}
|
|
160
|
-
case "agent-any": {
|
|
161
|
-
checks.push({
|
|
162
|
-
name: "Has agent-* label",
|
|
163
|
-
status: "ok",
|
|
164
|
-
detail: "agent-any",
|
|
165
|
-
});
|
|
166
|
-
checks.push({
|
|
167
|
-
name: "Model resolves from agent-* label",
|
|
168
|
-
status: "ok",
|
|
169
|
-
detail: `model picked at dispatch time; defaults to "${config.models.default}" when usage ties`,
|
|
170
|
-
});
|
|
171
|
-
break;
|
|
172
|
-
}
|
|
173
|
-
case "matched": {
|
|
174
|
-
checks.push({
|
|
175
|
-
name: "Has agent-* label",
|
|
176
|
-
status: "ok",
|
|
177
|
-
detail: `agent-${modelResolution.model}`,
|
|
178
|
-
});
|
|
179
|
-
checks.push({
|
|
180
|
-
name: "Model resolves from agent-* label",
|
|
181
|
-
status: "ok",
|
|
182
|
-
detail: `model "${modelResolution.model}"`,
|
|
183
|
-
});
|
|
184
|
-
break;
|
|
185
|
-
}
|
|
186
|
-
case "disabled-fallback": {
|
|
187
|
-
checks.push({
|
|
188
|
-
name: "Has agent-* label",
|
|
189
|
-
status: "ok",
|
|
190
|
-
detail: `agent-${modelResolution.requestedModel}`,
|
|
191
|
-
});
|
|
192
|
-
checks.push({
|
|
193
|
-
name: "Model resolves from agent-* label",
|
|
194
|
-
status: "ok",
|
|
195
|
-
detail: `agent-${modelResolution.requestedModel} disabled; falling back to model "${modelResolution.fallbackModel}"`,
|
|
196
|
-
});
|
|
197
|
-
break;
|
|
198
|
-
}
|
|
199
|
-
/* v8 ignore next @preserve */
|
|
200
|
-
default: {
|
|
201
|
-
break;
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
let resolvedModel = config.models.default;
|
|
205
|
-
if (modelResolution.kind === "matched") {
|
|
206
|
-
resolvedModel = modelResolution.model;
|
|
207
|
-
}
|
|
208
|
-
else if (modelResolution.kind === "agent-any") {
|
|
209
|
-
resolvedModel = AGENT_ANY_MODEL;
|
|
210
|
-
}
|
|
211
|
-
else if (modelResolution.kind === "disabled-fallback") {
|
|
212
|
-
resolvedModel = modelResolution.fallbackModel;
|
|
213
|
-
}
|
|
214
|
-
return { resolvedModel, checks };
|
|
215
|
-
}
|
|
216
|
-
function buildRepoChecks(raw, config, ticket) {
|
|
217
|
-
const repositoryResolution = resolveRepositoryFor({
|
|
218
|
-
description: raw.description,
|
|
219
|
-
config,
|
|
220
|
-
ticket,
|
|
221
|
-
});
|
|
222
|
-
const checks = [];
|
|
223
|
-
if (repositoryResolution.kind === "ok") {
|
|
224
|
-
checks.push({
|
|
225
|
-
name: "Description mentions known repo",
|
|
226
|
-
status: "ok",
|
|
227
|
-
detail: repositoryResolution.repository,
|
|
228
|
-
});
|
|
229
|
-
const repoDir = join(config.workspace.projectDir, repositoryResolution.repository);
|
|
230
|
-
if (existsSync(repoDir)) {
|
|
231
|
-
checks.push({
|
|
232
|
-
name: "Resolved repo is cloned locally",
|
|
233
|
-
status: "ok",
|
|
234
|
-
detail: repoDir,
|
|
235
|
-
});
|
|
236
|
-
}
|
|
237
|
-
else {
|
|
238
|
-
checks.push({
|
|
239
|
-
name: "Resolved repo is cloned locally",
|
|
240
|
-
status: "fail",
|
|
241
|
-
detail: `${repositoryResolution.repository} not found at ${repoDir} — run \`crew setup repos ${repositoryResolution.repository}\``,
|
|
242
|
-
failureSummary: `resolved repo ${repositoryResolution.repository} is not cloned locally`,
|
|
243
|
-
});
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
else {
|
|
247
|
-
checks.push({
|
|
248
|
-
name: "Description mentions known repo",
|
|
249
|
-
status: "fail",
|
|
250
|
-
detail: `no entry from workspace.knownRepositories (${config.workspace.knownRepositories.join(", ")}) appears in description`,
|
|
251
|
-
failureSummary: "description does not mention a known repo",
|
|
252
|
-
});
|
|
253
|
-
checks.push({
|
|
254
|
-
name: "Resolved repo is cloned locally",
|
|
255
|
-
status: "skipped",
|
|
256
|
-
});
|
|
257
|
-
}
|
|
258
|
-
/* v8 ignore else @preserve -- resolved repository is empty only on the fail branch above; the field is only consumed on the ok path */
|
|
259
|
-
const resolvedRepository = repositoryResolution.kind === "ok" ? repositoryResolution.repository : "";
|
|
260
|
-
return { resolvedRepository, checks };
|
|
261
|
-
}
|
|
262
|
-
function buildChildrenCheck(raw) {
|
|
263
|
-
if (raw.hasChildren) {
|
|
264
|
-
return {
|
|
265
|
-
name: "Has no sub-issues",
|
|
266
|
-
status: "fail",
|
|
267
|
-
detail: "parent ticket with sub-issues — groundcrew works sub-issues, not parents; label a sub-issue or detach the children",
|
|
268
|
-
failureSummary: "parent ticket with sub-issues — groundcrew works sub-issues, not parents",
|
|
269
|
-
};
|
|
270
|
-
}
|
|
271
|
-
return { name: "Has no sub-issues", status: "ok" };
|
|
272
|
-
}
|
|
273
|
-
async function runEligibilityChecks(arguments_) {
|
|
274
|
-
const { ticket, raw, config, resolvedRepository, resolvedModel, dependencies, eligibility } = arguments_;
|
|
275
|
-
const blockers = await dependencies.fetchBlockersFor({ ticket, uuid: raw.uuid });
|
|
276
|
-
const groundcrewIssue = {
|
|
277
|
-
id: ticket,
|
|
278
|
-
uuid: raw.uuid,
|
|
279
|
-
title: raw.title,
|
|
280
|
-
status: raw.stateName,
|
|
281
|
-
statusId: "",
|
|
282
|
-
assignee: "",
|
|
283
|
-
updatedAt: "",
|
|
284
|
-
teamId: raw.teamId,
|
|
285
|
-
/* v8 ignore next @preserve -- fetchRawLinearIssue always populates stateType (defaults to "") so the ?? guard is belt-and-suspenders */
|
|
286
|
-
stateType: raw.stateType ?? "",
|
|
287
|
-
repository: resolvedRepository,
|
|
288
|
-
model: resolvedModel,
|
|
289
|
-
blockers: [...blockers],
|
|
290
|
-
hasMoreBlockers: raw.hasMoreBlockers,
|
|
291
|
-
};
|
|
292
|
-
const blockerClassification = classifyBlockers([groundcrewIssue]);
|
|
293
|
-
const [firstSkip] = blockerClassification.skips;
|
|
294
|
-
if (firstSkip !== undefined) {
|
|
295
|
-
if (firstSkip.eventReason === "blockers_paginated") {
|
|
296
|
-
eligibility.push({
|
|
297
|
-
name: "No active blockers",
|
|
298
|
-
status: "fail",
|
|
299
|
-
detail: "blockers exceeded the v1 relation page size",
|
|
300
|
-
failureSummary: "blockers exceeded the v1 relation page size",
|
|
301
|
-
});
|
|
302
|
-
return false;
|
|
303
|
-
}
|
|
304
|
-
/* v8 ignore next @preserve -- firstSkip.blockers is always set for "blocked" and "blockers_paginated" skip reasons */
|
|
305
|
-
const blockerIds = firstSkip.blockers ?? [];
|
|
306
|
-
eligibility.push({
|
|
307
|
-
name: "No active blockers",
|
|
308
|
-
status: "fail",
|
|
309
|
-
detail: blockerIds.join(", "),
|
|
310
|
-
failureSummary: `blocked by ${blockerIds.join(", ")}`,
|
|
311
|
-
});
|
|
312
|
-
return false;
|
|
313
|
-
}
|
|
314
|
-
eligibility.push({ name: "No active blockers", status: "ok" });
|
|
315
|
-
const usage = await dependencies.fetchUsage();
|
|
316
|
-
const usageExhaustion = classifyUsageExhaustion(config, usage);
|
|
317
|
-
const exhausted = new Set(usageExhaustion.map((exhaustion) => exhaustion.model));
|
|
318
|
-
let model = resolvedModel;
|
|
319
|
-
let resolvedFromAny = "";
|
|
320
|
-
if (model === AGENT_ANY_MODEL) {
|
|
321
|
-
const picked = pickBestModel(config, usage, exhausted);
|
|
322
|
-
if (picked === undefined) {
|
|
323
|
-
eligibility.push({
|
|
324
|
-
name: "Model usage under sessionLimitPercentage",
|
|
325
|
-
status: "fail",
|
|
326
|
-
detail: "agent-any but no model has available capacity",
|
|
327
|
-
failureSummary: "agent-any has no model with available capacity",
|
|
328
|
-
});
|
|
329
|
-
return false;
|
|
330
|
-
}
|
|
331
|
-
model = picked;
|
|
332
|
-
resolvedFromAny = `; agent-any resolved to model "${picked}"`;
|
|
333
|
-
}
|
|
334
|
-
const exhaustedUsage = usageExhaustion.find((exhaustion) => exhaustion.model === model);
|
|
335
|
-
eligibility.push(exhaustedUsage === undefined
|
|
336
|
-
? modelUsageOkCheck({ config, model, usage, resolvedFromAny })
|
|
337
|
-
: usageExhaustionCheck(exhaustedUsage));
|
|
338
|
-
const inProgress = await dependencies.countInProgress();
|
|
339
|
-
const cap = config.orchestrator.maximumInProgress;
|
|
340
|
-
const capOk = inProgress < cap;
|
|
341
|
-
const capCheck = {
|
|
342
|
-
name: "In-progress cap not hit",
|
|
343
|
-
status: capOk ? "ok" : "fail",
|
|
344
|
-
detail: `${inProgress}/${cap} used`,
|
|
345
|
-
};
|
|
346
|
-
if (!capOk) {
|
|
347
|
-
capCheck.failureSummary = `in-progress cap is full (${inProgress}/${cap} used)`;
|
|
348
|
-
}
|
|
349
|
-
eligibility.push(capCheck);
|
|
350
|
-
return eligibility.every((check) => check.status === "ok");
|
|
351
|
-
}
|
|
352
|
-
function modelUsageOkCheck(arguments_) {
|
|
353
|
-
const { config, model, usage, resolvedFromAny } = arguments_;
|
|
354
|
-
const sessionPercent = ((usage[model]?.session ?? 0) * 100).toFixed(0);
|
|
355
|
-
return {
|
|
356
|
-
name: `Model "${model}" usage under sessionLimitPercentage`,
|
|
357
|
-
status: "ok",
|
|
358
|
-
detail: `${sessionPercent}% (limit ${config.orchestrator.sessionLimitPercentage}%)${resolvedFromAny}`,
|
|
359
|
-
};
|
|
360
|
-
}
|
|
361
|
-
function usageExhaustionCheck(exhaustion) {
|
|
362
|
-
if (exhaustion.kind === "session") {
|
|
363
|
-
return {
|
|
364
|
-
name: `Model "${exhaustion.model}" usage under sessionLimitPercentage`,
|
|
365
|
-
status: "fail",
|
|
366
|
-
detail: `${exhaustion.usedPercentage.toFixed(0)}% (limit ${exhaustion.limitPercentage}%)`,
|
|
367
|
-
failureSummary: `${exhaustion.model} session usage ${exhaustion.usedPercentage.toFixed(0)}% over ${exhaustion.limitPercentage}% limit`,
|
|
368
|
-
};
|
|
369
|
-
}
|
|
370
|
-
return {
|
|
371
|
-
name: `Model "${exhaustion.model}" weekly usage within paced budget`,
|
|
372
|
-
status: "fail",
|
|
373
|
-
detail: `${exhaustion.usedPercentage.toFixed(1)}% (paced budget ${exhaustion.allowedPercentage.toFixed(1)}%, resets in ${exhaustion.resetMinutes}m)`,
|
|
374
|
-
failureSummary: `${exhaustion.model} weekly usage ${exhaustion.usedPercentage.toFixed(1)}% over ${exhaustion.allowedPercentage.toFixed(1)}% paced budget`,
|
|
375
|
-
};
|
|
376
|
-
}
|
|
377
|
-
/**
|
|
378
|
-
* Fetches Linear and adds the "Ticket exists in Linear" + status checks to
|
|
379
|
-
* the resolution section. Returns the raw issue on success so the orchestrator
|
|
380
|
-
* can continue with label/repo/eligibility. On failure, returns enough
|
|
381
|
-
* context to render an `unresolvable` verdict.
|
|
382
|
-
*/
|
|
383
|
-
async function probeLinear(deps, upperTicket) {
|
|
384
|
-
if (deps.fetchRawIssue === undefined) {
|
|
385
|
-
return { linearStatus: { kind: "skipped" }, resolution: [] };
|
|
386
|
-
}
|
|
387
|
-
try {
|
|
388
|
-
const raw = await deps.fetchRawIssue({ ticket: upperTicket });
|
|
389
|
-
/* v8 ignore next @preserve -- fetchRawLinearIssue always populates stateType (defaults to "") so the ?? guard is belt-and-suspenders */
|
|
390
|
-
const stateType = raw.stateType ?? "";
|
|
391
|
-
const isTerminal = isTerminalStateType(stateType);
|
|
392
|
-
const resolution = [
|
|
393
|
-
{ name: "Ticket exists in Linear", status: "ok", detail: `"${raw.title}"` },
|
|
394
|
-
];
|
|
395
|
-
if (stateType === "unstarted") {
|
|
396
|
-
resolution.push({ name: "Status is Todo", status: "ok" });
|
|
397
|
-
}
|
|
398
|
-
else {
|
|
399
|
-
resolution.push({
|
|
400
|
-
name: "Status is Todo",
|
|
401
|
-
status: "fail",
|
|
402
|
-
detail: `current: ${raw.stateName}`,
|
|
403
|
-
failureSummary: `status is ${raw.stateName} (state.type=${stateType}, need unstarted)`,
|
|
404
|
-
});
|
|
405
|
-
}
|
|
406
|
-
return {
|
|
407
|
-
linearStatus: { kind: isTerminal ? "terminal" : "non-terminal", stateName: raw.stateName },
|
|
408
|
-
resolution,
|
|
409
|
-
title: raw.title,
|
|
410
|
-
raw,
|
|
411
|
-
};
|
|
412
|
-
}
|
|
413
|
-
catch (error) {
|
|
414
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
415
|
-
return {
|
|
416
|
-
linearStatus: { kind: "unresolvable", reason: message },
|
|
417
|
-
resolution: [{ name: "Ticket exists in Linear", status: "fail", detail: message }],
|
|
418
|
-
};
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
function probeRunStateSection(deps, ticket) {
|
|
422
|
-
const state = deps.readRunState(ticket);
|
|
423
|
-
if (state === undefined) {
|
|
424
|
-
return {
|
|
425
|
-
checks: [{ name: "Local run state", status: "skipped", detail: "none found" }],
|
|
426
|
-
state: undefined,
|
|
427
|
-
};
|
|
428
|
-
}
|
|
429
|
-
const checks = [
|
|
430
|
-
{ name: "Local run state", status: "ok", detail: state.state },
|
|
431
|
-
{ name: "Recorded model", status: "ok", detail: state.model },
|
|
432
|
-
{ name: "Recorded worktree", status: "ok", detail: state.worktreeDir },
|
|
433
|
-
{ name: "Recorded branch", status: "ok", detail: state.branchName },
|
|
434
|
-
{ name: "Resume count", status: "ok", detail: String(state.resumeCount) },
|
|
435
|
-
];
|
|
436
|
-
if (state.reason !== undefined) {
|
|
437
|
-
checks.push({ name: "Last reason", status: "ok", detail: state.reason });
|
|
438
|
-
}
|
|
439
|
-
if (state.detail !== undefined) {
|
|
440
|
-
checks.push({ name: "Last detail", status: "ok", detail: state.detail });
|
|
441
|
-
}
|
|
442
|
-
return { checks, state };
|
|
443
|
-
}
|
|
444
|
-
async function probeWorktreeSection(deps, ticket) {
|
|
445
|
-
const entry = deps.findWorktree(ticket);
|
|
446
|
-
if (entry === undefined) {
|
|
447
|
-
return {
|
|
448
|
-
checks: [
|
|
449
|
-
{
|
|
450
|
-
name: "Host worktree exists",
|
|
451
|
-
status: "fail",
|
|
452
|
-
detail: "no worktree found for this ticket",
|
|
453
|
-
},
|
|
454
|
-
],
|
|
455
|
-
status: { kind: "absent" },
|
|
456
|
-
entry: undefined,
|
|
457
|
-
};
|
|
458
|
-
}
|
|
459
|
-
const dirtiness = await deps.probeWorkingTree({ worktreeDir: entry.dir });
|
|
460
|
-
const checks = [{ name: "Host worktree exists", status: "ok", detail: entry.dir }];
|
|
461
|
-
let status;
|
|
462
|
-
if (dirtiness.kind === "clean") {
|
|
463
|
-
checks.push({ name: "Working tree clean", status: "ok" });
|
|
464
|
-
status = { kind: "present-clean" };
|
|
465
|
-
}
|
|
466
|
-
else if (dirtiness.kind === "dirty") {
|
|
467
|
-
checks.push({
|
|
468
|
-
name: "Working tree clean",
|
|
469
|
-
status: "fail",
|
|
470
|
-
detail: `${dirtiness.modified} modified, ${dirtiness.untracked} untracked`,
|
|
471
|
-
});
|
|
472
|
-
status = {
|
|
473
|
-
kind: "present-dirty",
|
|
474
|
-
modified: dirtiness.modified,
|
|
475
|
-
untracked: dirtiness.untracked,
|
|
476
|
-
};
|
|
477
|
-
}
|
|
478
|
-
else {
|
|
479
|
-
// dirtiness.kind === "unknown"
|
|
480
|
-
checks.push({ name: "Working tree clean", status: "skipped", detail: "could not inspect" });
|
|
481
|
-
status = { kind: "present-unknown-dirtiness", reason: "git status failed" };
|
|
482
|
-
}
|
|
483
|
-
checks.push({ name: "Branch checked out", status: "ok", detail: entry.branchName });
|
|
484
|
-
return { checks, status, entry };
|
|
485
|
-
}
|
|
486
|
-
async function probeWorkspaceSection(deps, ticket) {
|
|
487
|
-
const probe = await deps.probeWorkspaces();
|
|
488
|
-
if (probe.kind === "unavailable") {
|
|
489
|
-
return {
|
|
490
|
-
checks: [
|
|
491
|
-
{ name: "Workspace pane open", status: "skipped", detail: "workspace probe unavailable" },
|
|
492
|
-
],
|
|
493
|
-
workspaceName: undefined,
|
|
494
|
-
};
|
|
495
|
-
}
|
|
496
|
-
if (probe.names.has(ticket)) {
|
|
497
|
-
const hint = await deps.workspaceAccessHint(ticket);
|
|
498
|
-
const detail = hint === undefined ? ticket : `${ticket} — attach: \`${hint.command}\``;
|
|
499
|
-
return {
|
|
500
|
-
checks: [{ name: "Workspace pane open", status: "ok", detail }],
|
|
501
|
-
workspaceName: ticket,
|
|
502
|
-
};
|
|
503
|
-
}
|
|
504
|
-
return {
|
|
505
|
-
checks: [
|
|
506
|
-
{ name: "Workspace pane open", status: "fail", detail: "no pane found for this ticket" },
|
|
507
|
-
],
|
|
508
|
-
workspaceName: undefined,
|
|
509
|
-
};
|
|
510
|
-
}
|
|
511
|
-
function repoDirFromEntry(entry, deps) {
|
|
512
|
-
return `${deps.config.workspace.projectDir}/${entry.repository}`;
|
|
513
|
-
}
|
|
514
|
-
async function probeLocalBranchSection(deps, entry) {
|
|
515
|
-
if (entry === undefined) {
|
|
516
|
-
return {
|
|
517
|
-
checks: [],
|
|
518
|
-
skipReason: "repo dir unresolved",
|
|
519
|
-
probe: { kind: "absent" },
|
|
520
|
-
branch: undefined,
|
|
521
|
-
};
|
|
522
|
-
}
|
|
523
|
-
const repoDir = repoDirFromEntry(entry, deps);
|
|
524
|
-
const defaultBranch = await deps.resolveDefaultBranch({ repoDir });
|
|
525
|
-
const probe = await deps.probeLocalBranch({
|
|
526
|
-
repoDir,
|
|
527
|
-
branch: entry.branchName,
|
|
528
|
-
remote: deps.config.git.remote,
|
|
529
|
-
defaultBranch,
|
|
530
|
-
});
|
|
531
|
-
if (probe.kind === "present") {
|
|
532
|
-
const defaultBranchName = probe.defaultBranch ?? deps.config.git.defaultBranch;
|
|
533
|
-
return {
|
|
534
|
-
checks: [
|
|
535
|
-
{
|
|
536
|
-
name: "Local branch exists",
|
|
537
|
-
status: "ok",
|
|
538
|
-
detail: `${entry.branchName}, ${probe.ahead} ahead / ${probe.behind} behind ${deps.config.git.remote}/${defaultBranchName}`,
|
|
539
|
-
},
|
|
540
|
-
],
|
|
541
|
-
skipReason: "",
|
|
542
|
-
probe,
|
|
543
|
-
branch: entry.branchName,
|
|
544
|
-
};
|
|
545
|
-
}
|
|
546
|
-
if (probe.kind === "absent") {
|
|
547
|
-
return {
|
|
548
|
-
checks: [{ name: "Local branch exists", status: "fail", detail: "branch not in git" }],
|
|
549
|
-
skipReason: "",
|
|
550
|
-
probe,
|
|
551
|
-
branch: entry.branchName,
|
|
552
|
-
};
|
|
553
|
-
}
|
|
554
|
-
// probe.kind === "unknown"
|
|
555
|
-
return {
|
|
556
|
-
checks: [{ name: "Local branch exists", status: "skipped", detail: probe.reason }],
|
|
557
|
-
skipReason: "",
|
|
558
|
-
probe,
|
|
559
|
-
branch: entry.branchName,
|
|
560
|
-
};
|
|
561
|
-
}
|
|
562
|
-
async function probeRemoteBranchSection(deps, entry) {
|
|
563
|
-
if (entry === undefined) {
|
|
564
|
-
return { checks: [], skipReason: "repo dir unresolved", probe: { kind: "absent" } };
|
|
565
|
-
}
|
|
566
|
-
const repoDir = repoDirFromEntry(entry, deps);
|
|
567
|
-
const checkName = `Branch present on ${deps.config.git.remote}`;
|
|
568
|
-
const probe = await deps.probeRemoteBranch({
|
|
569
|
-
repoDir,
|
|
570
|
-
branch: entry.branchName,
|
|
571
|
-
remote: deps.config.git.remote,
|
|
572
|
-
doFetch: deps.doFetch,
|
|
573
|
-
});
|
|
574
|
-
if (probe.kind === "present") {
|
|
575
|
-
return { checks: [{ name: checkName, status: "ok" }], skipReason: "", probe };
|
|
576
|
-
}
|
|
577
|
-
if (probe.kind === "absent") {
|
|
578
|
-
return {
|
|
579
|
-
checks: [{ name: checkName, status: "fail", detail: "not pushed" }],
|
|
580
|
-
skipReason: "",
|
|
581
|
-
probe,
|
|
582
|
-
};
|
|
583
|
-
}
|
|
584
|
-
// probe.kind === "unknown"
|
|
585
|
-
return {
|
|
586
|
-
checks: [{ name: checkName, status: "skipped", detail: probe.reason }],
|
|
587
|
-
skipReason: "",
|
|
588
|
-
probe,
|
|
589
|
-
};
|
|
590
|
-
}
|
|
591
|
-
async function probePullRequestSection(deps, entry) {
|
|
592
|
-
if (entry === undefined) {
|
|
593
|
-
return { checks: [], skipReason: "repo dir unresolved", probe: { kind: "absent" } };
|
|
594
|
-
}
|
|
595
|
-
const repoDir = repoDirFromEntry(entry, deps);
|
|
596
|
-
const probe = await deps.probePullRequest({ repoDir, branch: entry.branchName });
|
|
597
|
-
if (probe.kind === "open" || probe.kind === "merged") {
|
|
598
|
-
return {
|
|
599
|
-
checks: [
|
|
600
|
-
{
|
|
601
|
-
name: "Open PR for this branch",
|
|
602
|
-
status: "ok",
|
|
603
|
-
detail: `#${probe.number} ${probe.url}`,
|
|
604
|
-
},
|
|
605
|
-
],
|
|
606
|
-
skipReason: "",
|
|
607
|
-
probe,
|
|
608
|
-
};
|
|
609
|
-
}
|
|
610
|
-
if (probe.kind === "absent") {
|
|
611
|
-
return {
|
|
612
|
-
checks: [{ name: "Open PR for this branch", status: "fail", detail: "none found" }],
|
|
613
|
-
skipReason: "",
|
|
614
|
-
probe,
|
|
615
|
-
};
|
|
616
|
-
}
|
|
617
|
-
if (probe.kind === "gh-missing") {
|
|
618
|
-
return {
|
|
619
|
-
checks: [
|
|
620
|
-
{ name: "Open PR for this branch", status: "skipped", detail: "gh CLI not on PATH" },
|
|
621
|
-
],
|
|
622
|
-
skipReason: "",
|
|
623
|
-
probe,
|
|
624
|
-
};
|
|
625
|
-
}
|
|
626
|
-
// probe.kind === "unknown"
|
|
627
|
-
return {
|
|
628
|
-
checks: [{ name: "Open PR for this branch", status: "skipped", detail: probe.reason }],
|
|
629
|
-
skipReason: "",
|
|
630
|
-
probe,
|
|
631
|
-
};
|
|
632
|
-
}
|
|
633
|
-
async function runPreDispatch(input) {
|
|
634
|
-
const { ticket, raw, config, dependencies } = input;
|
|
635
|
-
const resolutionExtra = [];
|
|
636
|
-
const eligibility = [];
|
|
637
|
-
const { resolvedModel, checks: modelChecks } = buildModelChecks(raw, config);
|
|
638
|
-
resolutionExtra.push(...modelChecks);
|
|
639
|
-
// Children check comes before repo checks: a parent ticket is a
|
|
640
|
-
// structural property of the issue itself (filtered out by `fetchBoard`,
|
|
641
|
-
// so the dispatcher will never act on it). Reporting that first beats
|
|
642
|
-
// surfacing "your repo isn't cloned" for a ticket groundcrew won't pick
|
|
643
|
-
// up either way.
|
|
644
|
-
resolutionExtra.push(buildChildrenCheck(raw));
|
|
645
|
-
const { resolvedRepository, checks: repoChecks } = buildRepoChecks(raw, config, ticket);
|
|
646
|
-
resolutionExtra.push(...repoChecks);
|
|
647
|
-
if (input.statusCheckFailed) {
|
|
648
|
-
/* v8 ignore next @preserve -- statusFailureSummary is always set when statusCheckFailed is true; nullish fallback is defensive */
|
|
649
|
-
const reason = input.statusFailureSummary ?? "status not Todo";
|
|
650
|
-
return { resolutionExtra, eligibility, verdict: { kind: "ineligible", reason } };
|
|
651
|
-
}
|
|
652
|
-
const resolutionFail = resolutionExtra.find((check) => check.status === "fail");
|
|
653
|
-
if (resolutionFail !== undefined) {
|
|
654
|
-
/* v8 ignore next @preserve -- failureSummary is always set on resolution fail paths; .name fallback is defensive */
|
|
655
|
-
const reason = resolutionFail.failureSummary ?? resolutionFail.name;
|
|
656
|
-
return { resolutionExtra, eligibility, verdict: { kind: "ineligible", reason } };
|
|
657
|
-
}
|
|
658
|
-
const allEligibilityOk = await runEligibilityChecks({
|
|
659
|
-
ticket,
|
|
660
|
-
raw,
|
|
661
|
-
config,
|
|
662
|
-
resolvedRepository,
|
|
663
|
-
resolvedModel,
|
|
664
|
-
dependencies,
|
|
665
|
-
eligibility,
|
|
666
|
-
});
|
|
667
|
-
if (!allEligibilityOk) {
|
|
668
|
-
const firstEligibilityFail = eligibility.find((check) => check.status === "fail");
|
|
669
|
-
/* v8 ignore next 4 @preserve -- firstEligibilityFail is always defined when allEligibilityOk is false; fallback is defensive */
|
|
670
|
-
const reason = firstEligibilityFail?.failureSummary ??
|
|
671
|
-
firstEligibilityFail?.name ??
|
|
672
|
-
"eligibility check failed";
|
|
673
|
-
return { resolutionExtra, eligibility, verdict: { kind: "ineligible", reason } };
|
|
674
|
-
}
|
|
675
|
-
return { resolutionExtra, eligibility, verdict: { kind: "would-dispatch" } };
|
|
676
|
-
}
|
|
677
|
-
/**
|
|
678
|
-
* Pure-with-async orchestrator that gathers all sections plus the verdict.
|
|
679
|
-
* All I/O happens via injected probes — the function itself does no
|
|
680
|
-
* filesystem, network, or stdout work.
|
|
681
|
-
*/
|
|
682
|
-
export async function ticketDoctor(dependencies) {
|
|
683
|
-
// Linear's GraphQL API treats ticket ids as uppercase. Local-state probes —
|
|
684
|
-
// worktree dirs, workspace pane names, branch-name fallbacks — are derived
|
|
685
|
-
// from the lowercase convention set by `setupWorkspaceCli`. Mixing cases is
|
|
686
|
-
// the root cause of "no worktree found" when the user types `--ticket
|
|
687
|
-
// HRD-442`.
|
|
688
|
-
const upperTicket = dependencies.ticket.toUpperCase();
|
|
689
|
-
const lowerTicket = dependencies.ticket.toLowerCase();
|
|
690
|
-
const skipReasons = emptySkipReasons();
|
|
691
|
-
const linearResult = await probeLinear(dependencies, upperTicket);
|
|
692
|
-
const resolution = [...linearResult.resolution];
|
|
693
|
-
const runStateResult = probeRunStateSection(dependencies, lowerTicket);
|
|
694
|
-
const worktreeResult = await probeWorktreeSection(dependencies, lowerTicket);
|
|
695
|
-
const workspaceResult = await probeWorkspaceSection(dependencies, lowerTicket);
|
|
696
|
-
const localResult = await probeLocalBranchSection(dependencies, worktreeResult.entry);
|
|
697
|
-
const remoteResult = await probeRemoteBranchSection(dependencies, worktreeResult.entry);
|
|
698
|
-
const prResult = await probePullRequestSection(dependencies, worktreeResult.entry);
|
|
699
|
-
skipReasons.localBranch = localResult.skipReason;
|
|
700
|
-
skipReasons.remoteBranch = remoteResult.skipReason;
|
|
701
|
-
skipReasons.pullRequest = prResult.skipReason;
|
|
702
|
-
// `--no-linear` synthesizes a non-terminal kind so the in-flight check can
|
|
703
|
-
// still fire from local state alone. The placeholder name is read only by
|
|
704
|
-
// `decidePostDispatchVerdict` and never surfaced to the user.
|
|
705
|
-
const linearForVerdict = linearResult.linearStatus.kind === "skipped"
|
|
706
|
-
? { kind: "non-terminal", stateName: LINEAR_SKIPPED_STATE_NAME }
|
|
707
|
-
: linearResult.linearStatus;
|
|
708
|
-
const postVerdict = decidePostDispatchVerdict({
|
|
709
|
-
linear: linearForVerdict,
|
|
710
|
-
worktree: worktreeResult.status,
|
|
711
|
-
localBranch: localResult.probe,
|
|
712
|
-
remoteBranch: remoteResult.probe,
|
|
713
|
-
pullRequest: prResult.probe,
|
|
714
|
-
branch: worktreeResult.entry?.branchName ?? lowerTicket,
|
|
715
|
-
worktreeDir: worktreeResult.entry?.dir,
|
|
716
|
-
workspaceName: workspaceResult.workspaceName,
|
|
717
|
-
runState: runStateResult.state,
|
|
718
|
-
});
|
|
719
|
-
if (postVerdict !== undefined) {
|
|
720
|
-
skipReasons.resolution = "post-dispatch — pre-dispatch checks are irrelevant";
|
|
721
|
-
skipReasons.eligibility = "post-dispatch — pre-dispatch checks are irrelevant";
|
|
722
|
-
return buildResult({
|
|
723
|
-
upperTicket,
|
|
724
|
-
title: linearResult.title,
|
|
725
|
-
resolution,
|
|
726
|
-
eligibility: [],
|
|
727
|
-
runStateResult,
|
|
728
|
-
worktreeResult,
|
|
729
|
-
workspaceResult,
|
|
730
|
-
localResult,
|
|
731
|
-
remoteResult,
|
|
732
|
-
prResult,
|
|
733
|
-
skipReasons,
|
|
734
|
-
verdict: postVerdict,
|
|
735
|
-
});
|
|
736
|
-
}
|
|
737
|
-
// No post-dispatch verdict. Decide pre-dispatch path.
|
|
738
|
-
if (linearResult.linearStatus.kind === "skipped") {
|
|
739
|
-
// --no-linear with nothing locally actionable → lost. We cannot reach
|
|
740
|
-
// Linear, so the pre-dispatch resolution/eligibility checks are unavailable.
|
|
741
|
-
skipReasons.resolution = "--no-linear";
|
|
742
|
-
skipReasons.eligibility = "--no-linear";
|
|
743
|
-
return buildResult({
|
|
744
|
-
upperTicket,
|
|
745
|
-
title: linearResult.title,
|
|
746
|
-
resolution,
|
|
747
|
-
eligibility: [],
|
|
748
|
-
runStateResult,
|
|
749
|
-
worktreeResult,
|
|
750
|
-
workspaceResult,
|
|
751
|
-
localResult,
|
|
752
|
-
remoteResult,
|
|
753
|
-
prResult,
|
|
754
|
-
skipReasons,
|
|
755
|
-
verdict: {
|
|
756
|
-
kind: "lost",
|
|
757
|
-
reason: `no local state and no PR — re-dispatch via \`crew run --ticket ${lowerTicket}\` or move the ticket back to Todo in Linear`,
|
|
758
|
-
},
|
|
759
|
-
});
|
|
760
|
-
}
|
|
761
|
-
if (linearResult.linearStatus.kind === "unresolvable") {
|
|
762
|
-
const { reason } = linearResult.linearStatus;
|
|
763
|
-
skipReasons.eligibility = "ticket unresolved";
|
|
764
|
-
return buildResult({
|
|
765
|
-
upperTicket,
|
|
766
|
-
title: linearResult.title,
|
|
767
|
-
resolution,
|
|
768
|
-
eligibility: [],
|
|
769
|
-
runStateResult,
|
|
770
|
-
worktreeResult,
|
|
771
|
-
workspaceResult,
|
|
772
|
-
localResult,
|
|
773
|
-
remoteResult,
|
|
774
|
-
prResult,
|
|
775
|
-
skipReasons,
|
|
776
|
-
verdict: { kind: "unresolvable", reason },
|
|
777
|
-
});
|
|
778
|
-
}
|
|
779
|
-
// After the skipped/unresolvable branches above, linearStatus.kind is either
|
|
780
|
-
// "terminal" or "non-terminal", both of which carry the raw issue. TS can't
|
|
781
|
-
// see the invariant through the union, so we narrow with an explicit guard.
|
|
782
|
-
const { raw } = linearResult;
|
|
783
|
-
/* v8 ignore next 3 @preserve -- raw is defined whenever linearStatus.kind is terminal/non-terminal; the guard is for type narrowing only */
|
|
784
|
-
if (raw === undefined) {
|
|
785
|
-
throw new Error("ticketDoctor: invariant violated — raw Linear issue missing after status check");
|
|
786
|
-
}
|
|
787
|
-
// The "Status is Todo" check was already pushed by `probeLinear`; surface it
|
|
788
|
-
// as the verdict reason if it failed.
|
|
789
|
-
const statusCheck = resolution.find((check) => check.name === "Status is Todo");
|
|
790
|
-
const statusCheckFailed = statusCheck?.status === "fail";
|
|
791
|
-
const preDispatch = await runPreDispatch({
|
|
792
|
-
ticket: upperTicket,
|
|
793
|
-
raw,
|
|
794
|
-
config: dependencies.config,
|
|
795
|
-
dependencies,
|
|
796
|
-
statusCheckFailed,
|
|
797
|
-
statusFailureSummary: statusCheck?.failureSummary,
|
|
798
|
-
});
|
|
799
|
-
resolution.push(...preDispatch.resolutionExtra);
|
|
800
|
-
if (preDispatch.eligibility.length === 0 && preDispatch.verdict.kind === "ineligible") {
|
|
801
|
-
skipReasons.eligibility = "resolution checks failed";
|
|
802
|
-
}
|
|
803
|
-
return buildResult({
|
|
804
|
-
upperTicket,
|
|
805
|
-
title: linearResult.title,
|
|
806
|
-
resolution,
|
|
807
|
-
eligibility: preDispatch.eligibility,
|
|
808
|
-
runStateResult,
|
|
809
|
-
worktreeResult,
|
|
810
|
-
workspaceResult,
|
|
811
|
-
localResult,
|
|
812
|
-
remoteResult,
|
|
813
|
-
prResult,
|
|
814
|
-
skipReasons,
|
|
815
|
-
verdict: preDispatch.verdict,
|
|
816
|
-
});
|
|
817
|
-
}
|
|
818
|
-
function buildResult(input) {
|
|
819
|
-
return {
|
|
820
|
-
ticket: input.upperTicket,
|
|
821
|
-
...(input.title === undefined ? {} : { title: input.title }),
|
|
822
|
-
resolution: input.resolution,
|
|
823
|
-
eligibility: input.eligibility,
|
|
824
|
-
runState: input.runStateResult.checks,
|
|
825
|
-
worktree: input.worktreeResult.checks,
|
|
826
|
-
workspace: input.workspaceResult.checks,
|
|
827
|
-
localBranch: input.localResult.checks,
|
|
828
|
-
remoteBranch: input.remoteResult.checks,
|
|
829
|
-
pullRequest: input.prResult.checks,
|
|
830
|
-
skipReasons: input.skipReasons,
|
|
831
|
-
verdict: input.verdict,
|
|
832
|
-
};
|
|
833
|
-
}
|
|
834
|
-
/**
|
|
835
|
-
* Parses optional `--no-linear` and `--no-fetch` flags that follow
|
|
836
|
-
* `crew doctor --ticket <id>`. The ticket id is consumed by `cli.ts` before
|
|
837
|
-
* this point.
|
|
838
|
-
*/
|
|
839
|
-
export function parseTicketDoctorFlags(argv) {
|
|
840
|
-
let doLinear = true;
|
|
841
|
-
let doFetch = true;
|
|
842
|
-
for (const argument of argv) {
|
|
843
|
-
if (argument === "--no-linear") {
|
|
844
|
-
doLinear = false;
|
|
845
|
-
continue;
|
|
846
|
-
}
|
|
847
|
-
if (argument === "--no-fetch") {
|
|
848
|
-
doFetch = false;
|
|
849
|
-
continue;
|
|
850
|
-
}
|
|
851
|
-
throw new Error(`crew doctor --ticket: unknown argument: ${argument}`);
|
|
852
|
-
}
|
|
853
|
-
return { doLinear, doFetch };
|
|
854
|
-
}
|
|
855
|
-
function formatVerdict(verdict) {
|
|
856
|
-
switch (verdict.kind) {
|
|
857
|
-
case "pr-open": {
|
|
858
|
-
return `→ pr-open: ${verdict.url} (#${verdict.number})`;
|
|
859
|
-
}
|
|
860
|
-
case "pr-merged": {
|
|
861
|
-
return `→ pr-merged: ${verdict.url} (#${verdict.number})`;
|
|
862
|
-
}
|
|
863
|
-
case "interrupted": {
|
|
864
|
-
return `→ interrupted: ${verdict.reason}; ${verdict.nextStep}`;
|
|
865
|
-
}
|
|
866
|
-
case "failed-launch": {
|
|
867
|
-
return `→ failed-launch: ${verdict.reason}; ${verdict.nextStep}`;
|
|
868
|
-
}
|
|
869
|
-
case "in-flight": {
|
|
870
|
-
return `→ in-flight: ${verdict.reason}`;
|
|
871
|
-
}
|
|
872
|
-
case "recoverable": {
|
|
873
|
-
return `→ recoverable: ${verdict.reason}; ${verdict.nextStep}`;
|
|
874
|
-
}
|
|
875
|
-
case "would-dispatch": {
|
|
876
|
-
return "→ would be dispatched on next tick";
|
|
877
|
-
}
|
|
878
|
-
case "ineligible": {
|
|
879
|
-
return `→ ineligible: ${verdict.reason}`;
|
|
880
|
-
}
|
|
881
|
-
case "unresolvable": {
|
|
882
|
-
return `→ unresolvable: ${verdict.reason}`;
|
|
883
|
-
}
|
|
884
|
-
case "lost": {
|
|
885
|
-
return `→ lost: ${verdict.reason}`;
|
|
886
|
-
}
|
|
887
|
-
/* v8 ignore next 3 @preserve -- exhaustive over TicketDoctorVerdict.kind */
|
|
888
|
-
default: {
|
|
889
|
-
return `→ ${verdict.kind}`;
|
|
890
|
-
}
|
|
891
|
-
}
|
|
892
|
-
}
|
|
893
|
-
export function renderTicketDoctorResult(result) {
|
|
894
|
-
const sections = [
|
|
895
|
-
{
|
|
896
|
-
name: "Resolution",
|
|
897
|
-
checks: result.resolution,
|
|
898
|
-
...(result.skipReasons.resolution === ""
|
|
899
|
-
? {}
|
|
900
|
-
: { skipReason: result.skipReasons.resolution }),
|
|
901
|
-
},
|
|
902
|
-
{
|
|
903
|
-
name: "Eligibility",
|
|
904
|
-
checks: result.eligibility,
|
|
905
|
-
...(result.skipReasons.eligibility === ""
|
|
906
|
-
? {}
|
|
907
|
-
: { skipReason: result.skipReasons.eligibility }),
|
|
908
|
-
},
|
|
909
|
-
{
|
|
910
|
-
name: "Run state",
|
|
911
|
-
checks: result.runState,
|
|
912
|
-
...(result.skipReasons.runState === "" ? {} : { skipReason: result.skipReasons.runState }),
|
|
913
|
-
},
|
|
914
|
-
{
|
|
915
|
-
name: "Worktree",
|
|
916
|
-
checks: result.worktree,
|
|
917
|
-
...(result.skipReasons.worktree === "" ? {} : { skipReason: result.skipReasons.worktree }),
|
|
918
|
-
},
|
|
919
|
-
{
|
|
920
|
-
name: "Workspace",
|
|
921
|
-
checks: result.workspace,
|
|
922
|
-
...(result.skipReasons.workspace === "" ? {} : { skipReason: result.skipReasons.workspace }),
|
|
923
|
-
},
|
|
924
|
-
{
|
|
925
|
-
name: "Local branch",
|
|
926
|
-
checks: result.localBranch,
|
|
927
|
-
...(result.skipReasons.localBranch === ""
|
|
928
|
-
? {}
|
|
929
|
-
: { skipReason: result.skipReasons.localBranch }),
|
|
930
|
-
},
|
|
931
|
-
{
|
|
932
|
-
name: "Remote branch",
|
|
933
|
-
checks: result.remoteBranch,
|
|
934
|
-
...(result.skipReasons.remoteBranch === ""
|
|
935
|
-
? {}
|
|
936
|
-
: { skipReason: result.skipReasons.remoteBranch }),
|
|
937
|
-
},
|
|
938
|
-
{
|
|
939
|
-
name: "Pull request",
|
|
940
|
-
checks: result.pullRequest,
|
|
941
|
-
...(result.skipReasons.pullRequest === ""
|
|
942
|
-
? {}
|
|
943
|
-
: { skipReason: result.skipReasons.pullRequest }),
|
|
944
|
-
},
|
|
945
|
-
];
|
|
946
|
-
return renderTicketCheckResult({
|
|
947
|
-
command: "doctor --ticket",
|
|
948
|
-
argument: result.ticket,
|
|
949
|
-
...(result.title === undefined ? {} : { title: result.title }),
|
|
950
|
-
sections,
|
|
951
|
-
verdict: formatVerdict(result.verdict),
|
|
952
|
-
});
|
|
953
|
-
}
|
|
954
|
-
/* v8 ignore start @preserve -- production wiring; covered by integration smoke tests */
|
|
955
|
-
export async function runTicketDoctor(parsed) {
|
|
956
|
-
const config = await loadConfig();
|
|
957
|
-
const linearClient = lazyLinearClient(getLinearClient);
|
|
958
|
-
const fetchRawIssue = parsed.doLinear
|
|
959
|
-
? async ({ ticket }) => await fetchRawLinearIssue({ client: linearClient(), ticket })
|
|
960
|
-
: undefined;
|
|
961
|
-
const result = await ticketDoctor({
|
|
962
|
-
config,
|
|
963
|
-
ticket: parsed.ticket,
|
|
964
|
-
fetchRawIssue,
|
|
965
|
-
fetchBlockersFor: async ({ ticket, uuid }) => await fetchBlockersForTicket({ client: linearClient(), ticket, uuid }),
|
|
966
|
-
fetchUsage: async () => await getUsageByModel(config),
|
|
967
|
-
countInProgress: async () => await fetchInProgressIssueCount({ client: linearClient() }),
|
|
968
|
-
findWorktree: (ticket) => worktrees.findByTicket(config, ticket)[0],
|
|
969
|
-
probeWorkspaces: async () => await workspaces.probe(config),
|
|
970
|
-
workspaceAccessHint: async (name) => await workspaces.accessHint(config, name),
|
|
971
|
-
probeWorkingTree: async ({ worktreeDir }) => await worktrees.probeWorkingTree({ worktreeDir }),
|
|
972
|
-
resolveDefaultBranch: async ({ repoDir }) => await resolveDefaultBranch({
|
|
973
|
-
repoDir,
|
|
974
|
-
remote: config.git.remote,
|
|
975
|
-
fallback: config.git.defaultBranch,
|
|
976
|
-
}),
|
|
977
|
-
probeLocalBranch: probeLocalBranchImpl,
|
|
978
|
-
probeRemoteBranch: probeRemoteBranchImpl,
|
|
979
|
-
probePullRequest: probePullRequestImpl,
|
|
980
|
-
readRunState: (ticket) => readRunState(config, ticket),
|
|
981
|
-
doFetch: parsed.doFetch,
|
|
982
|
-
});
|
|
983
|
-
for (const line of renderTicketDoctorResult(result)) {
|
|
984
|
-
writeOutput(line);
|
|
985
|
-
}
|
|
986
|
-
return (result.verdict.kind === "would-dispatch" ||
|
|
987
|
-
result.verdict.kind === "pr-open" ||
|
|
988
|
-
result.verdict.kind === "pr-merged");
|
|
989
|
-
}
|
|
990
|
-
// ───── production probes ─────
|
|
991
|
-
/**
|
|
992
|
-
* Reads the numeric exit status that `normalizeCommandError` in
|
|
993
|
-
* `commandRunner.ts` includes in failed-command error messages as
|
|
994
|
-
* `Exit status: <N>`. Returns undefined when no such line is present.
|
|
995
|
-
*/
|
|
996
|
-
function parseExitStatus(error) {
|
|
997
|
-
if (!(error instanceof Error)) {
|
|
998
|
-
return undefined;
|
|
999
|
-
}
|
|
1000
|
-
const match = /Exit status: (\d+)/.exec(error.message);
|
|
1001
|
-
if (match === null || match[1] === undefined) {
|
|
1002
|
-
return undefined;
|
|
1003
|
-
}
|
|
1004
|
-
return Number.parseInt(match[1], 10);
|
|
1005
|
-
}
|
|
1006
|
-
async function probeLocalBranchImpl(input) {
|
|
1007
|
-
if (!existsSync(input.repoDir)) {
|
|
1008
|
-
return { kind: "unknown", reason: `repo dir not found: ${input.repoDir}` };
|
|
1009
|
-
}
|
|
1010
|
-
try {
|
|
1011
|
-
await runCommandAsync("git", [
|
|
1012
|
-
"-C",
|
|
1013
|
-
input.repoDir,
|
|
1014
|
-
"rev-parse",
|
|
1015
|
-
"--verify",
|
|
1016
|
-
"-q",
|
|
1017
|
-
input.branch,
|
|
1018
|
-
]);
|
|
1019
|
-
}
|
|
1020
|
-
catch (error) {
|
|
1021
|
-
if (parseExitStatus(error) === 1) {
|
|
1022
|
-
return { kind: "absent" };
|
|
1023
|
-
}
|
|
1024
|
-
return {
|
|
1025
|
-
kind: "unknown",
|
|
1026
|
-
reason: error instanceof Error ? error.message : String(error),
|
|
1027
|
-
};
|
|
1028
|
-
}
|
|
1029
|
-
try {
|
|
1030
|
-
const output = await runCommandAsync("git", [
|
|
1031
|
-
"-C",
|
|
1032
|
-
input.repoDir,
|
|
1033
|
-
"rev-list",
|
|
1034
|
-
"--left-right",
|
|
1035
|
-
"--count",
|
|
1036
|
-
`${input.branch}...${input.remote}/${input.defaultBranch}`,
|
|
1037
|
-
]);
|
|
1038
|
-
const [aheadString, behindString] = output.trim().split(/\s+/);
|
|
1039
|
-
const ahead = Number.parseInt(aheadString ?? "0", 10);
|
|
1040
|
-
const behind = Number.parseInt(behindString ?? "0", 10);
|
|
1041
|
-
return { kind: "present", ahead, behind, defaultBranch: input.defaultBranch };
|
|
1042
|
-
}
|
|
1043
|
-
catch {
|
|
1044
|
-
return { kind: "present", ahead: 0, behind: 0, defaultBranch: input.defaultBranch };
|
|
1045
|
-
}
|
|
1046
|
-
}
|
|
1047
|
-
async function probeRemoteBranchImpl(input) {
|
|
1048
|
-
if (!existsSync(input.repoDir)) {
|
|
1049
|
-
return { kind: "unknown", reason: `repo dir not found: ${input.repoDir}` };
|
|
1050
|
-
}
|
|
1051
|
-
if (input.doFetch) {
|
|
1052
|
-
try {
|
|
1053
|
-
await runCommandAsync("git", [
|
|
1054
|
-
"-C",
|
|
1055
|
-
input.repoDir,
|
|
1056
|
-
"fetch",
|
|
1057
|
-
"--quiet",
|
|
1058
|
-
input.remote,
|
|
1059
|
-
input.branch,
|
|
1060
|
-
]);
|
|
1061
|
-
}
|
|
1062
|
-
catch {
|
|
1063
|
-
// Best-effort fetch; ls-remote below is the authoritative check.
|
|
1064
|
-
}
|
|
1065
|
-
}
|
|
1066
|
-
try {
|
|
1067
|
-
await runCommandAsync("git", [
|
|
1068
|
-
"-C",
|
|
1069
|
-
input.repoDir,
|
|
1070
|
-
"ls-remote",
|
|
1071
|
-
"--exit-code",
|
|
1072
|
-
input.remote,
|
|
1073
|
-
`refs/heads/${input.branch}`,
|
|
1074
|
-
]);
|
|
1075
|
-
return { kind: "present" };
|
|
1076
|
-
}
|
|
1077
|
-
catch (error) {
|
|
1078
|
-
if (parseExitStatus(error) === 2) {
|
|
1079
|
-
return { kind: "absent" };
|
|
1080
|
-
}
|
|
1081
|
-
return {
|
|
1082
|
-
kind: "unknown",
|
|
1083
|
-
reason: error instanceof Error ? error.message : String(error),
|
|
1084
|
-
};
|
|
1085
|
-
}
|
|
1086
|
-
}
|
|
1087
|
-
async function probePullRequestImpl(input) {
|
|
1088
|
-
const ghPath = await which("gh");
|
|
1089
|
-
if (ghPath === undefined) {
|
|
1090
|
-
return { kind: "gh-missing" };
|
|
1091
|
-
}
|
|
1092
|
-
let output;
|
|
1093
|
-
try {
|
|
1094
|
-
output = await runCommandAsync("gh", [
|
|
1095
|
-
"pr",
|
|
1096
|
-
"list",
|
|
1097
|
-
"--head",
|
|
1098
|
-
input.branch,
|
|
1099
|
-
"--state",
|
|
1100
|
-
"all",
|
|
1101
|
-
"--json",
|
|
1102
|
-
"number,url,state,mergedAt",
|
|
1103
|
-
], { cwd: input.repoDir });
|
|
1104
|
-
}
|
|
1105
|
-
catch (error) {
|
|
1106
|
-
return {
|
|
1107
|
-
kind: "unknown",
|
|
1108
|
-
reason: error instanceof Error ? error.message : String(error),
|
|
1109
|
-
};
|
|
1110
|
-
}
|
|
1111
|
-
try {
|
|
1112
|
-
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- gh's --json schema is fixed by our request fields (number,url,state,mergedAt)
|
|
1113
|
-
const parsed = JSON.parse(output);
|
|
1114
|
-
if (parsed.length === 0) {
|
|
1115
|
-
return { kind: "absent" };
|
|
1116
|
-
}
|
|
1117
|
-
const open = parsed.find((pullRequest) => pullRequest.state === "OPEN");
|
|
1118
|
-
if (open !== undefined) {
|
|
1119
|
-
return { kind: "open", number: open.number, url: open.url };
|
|
1120
|
-
}
|
|
1121
|
-
const merged = parsed.find((pullRequest) => pullRequest.mergedAt !== null && pullRequest.mergedAt !== undefined);
|
|
1122
|
-
if (merged !== undefined) {
|
|
1123
|
-
return { kind: "merged", number: merged.number, url: merged.url };
|
|
1124
|
-
}
|
|
1125
|
-
return { kind: "absent" };
|
|
1126
|
-
}
|
|
1127
|
-
catch (error) {
|
|
1128
|
-
return {
|
|
1129
|
-
kind: "unknown",
|
|
1130
|
-
reason: error instanceof Error ? error.message : String(error),
|
|
1131
|
-
};
|
|
1132
|
-
}
|
|
1133
|
-
}
|
|
1134
|
-
/* v8 ignore stop @preserve */
|