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