pr-shepherd 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/.claude-plugin/plugin.json +14 -0
  2. package/LICENSE +21 -0
  3. package/README.md +154 -0
  4. package/marketplace.json +8 -0
  5. package/package.json +62 -0
  6. package/skills/check/SKILL.md +70 -0
  7. package/skills/monitor/SKILL.md +108 -0
  8. package/skills/resolve/SKILL.md +85 -0
  9. package/src/cache/file-cache.mts +101 -0
  10. package/src/cache/file-cache.test.mts +91 -0
  11. package/src/cache/fix-attempts.mts +86 -0
  12. package/src/checks/classify.mts +80 -0
  13. package/src/checks/classify.test.mts +164 -0
  14. package/src/checks/triage.mock.test.mts +202 -0
  15. package/src/checks/triage.mts +88 -0
  16. package/src/cli.mts +423 -0
  17. package/src/commands/check.mts +188 -0
  18. package/src/commands/iterate.mock.test.mts +1111 -0
  19. package/src/commands/iterate.mts +371 -0
  20. package/src/commands/ready-delay.mts +117 -0
  21. package/src/commands/ready-delay.test.mts +116 -0
  22. package/src/commands/resolve.mts +92 -0
  23. package/src/commands/status.mts +173 -0
  24. package/src/comments/outdated.mts +18 -0
  25. package/src/comments/resolve.mts +179 -0
  26. package/src/config/load.mts +240 -0
  27. package/src/config.json +52 -0
  28. package/src/github/batch.mts +351 -0
  29. package/src/github/client.mts +207 -0
  30. package/src/github/client.test.mts +19 -0
  31. package/src/github/gql/batch-pr.gql +130 -0
  32. package/src/github/gql/dismiss-review.gql +7 -0
  33. package/src/github/gql/minimize-comment.gql +7 -0
  34. package/src/github/gql/multi-pr-status-paged.gql +31 -0
  35. package/src/github/gql/multi-pr-status.gql +32 -0
  36. package/src/github/gql/resolve-thread.gql +7 -0
  37. package/src/github/pagination.mts +86 -0
  38. package/src/github/pagination.test.mts +140 -0
  39. package/src/github/queries.mts +30 -0
  40. package/src/index.mts +17 -0
  41. package/src/merge-status/derive.mts +74 -0
  42. package/src/merge-status/derive.test.mts +130 -0
  43. package/src/reporters/json.mts +12 -0
  44. package/src/reporters/text.mts +140 -0
  45. package/src/types.mts +309 -0
  46. package/src/util/path-segment.mts +2 -0
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Triage failing check runs into four categories:
3
+ * - timeout: conclusion is TIMED_OUT or logs contain timeout markers.
4
+ * - infrastructure: conclusion is CANCELLED + infra-error log patterns.
5
+ * - actionable: compile error, test failure, lint violation from the PR's changes.
6
+ * - flaky: pre-existing or timing-dependent failures in untouched files.
7
+ *
8
+ * Shepherd computes and returns triage results, including `failureKind`, for
9
+ * downstream callers or slash-command logic to consume.
10
+ */
11
+
12
+ import { execFile as execFileCb } from "node:child_process";
13
+ import { promisify } from "node:util";
14
+ import type { ClassifiedCheck, TriagedCheck, FailureKind } from "../types.mts";
15
+ import { loadConfig } from "../config/load.mts";
16
+
17
+ const execFile = promisify(execFileCb);
18
+
19
+ const config = loadConfig();
20
+ const TIMEOUT_PATTERNS = config.checks.timeoutPatterns.map((p) => new RegExp(p, "i"));
21
+ const INFRA_PATTERNS = config.checks.infraPatterns.map((p) => new RegExp(p, "i"));
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Public API
25
+ // ---------------------------------------------------------------------------
26
+
27
+ /**
28
+ * Fetch logs and triage each failing check.
29
+ *
30
+ * Fetching logs is skipped for checks that have no `runId` (e.g. StatusContext nodes).
31
+ */
32
+ export function triageFailingChecks(failingChecks: ClassifiedCheck[]): Promise<TriagedCheck[]> {
33
+ return Promise.all(failingChecks.map((c) => triageCheck(c)));
34
+ }
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Internal
38
+ // ---------------------------------------------------------------------------
39
+
40
+ async function triageCheck(check: ClassifiedCheck): Promise<TriagedCheck> {
41
+ if (check.runId === null) {
42
+ return { ...check, failureKind: "actionable" };
43
+ }
44
+
45
+ const logExcerpt = await fetchFailedLogs(check.runId);
46
+ const failureKind = classifyLogs(check, logExcerpt);
47
+
48
+ return {
49
+ ...check,
50
+ failureKind,
51
+ logExcerpt: logExcerpt.slice(-config.checks.logMaxChars) || undefined,
52
+ };
53
+ }
54
+
55
+ async function fetchFailedLogs(runId: string): Promise<string> {
56
+ try {
57
+ const { stdout } = await execFile("gh", ["run", "view", runId, "--log-failed"], {
58
+ maxBuffer: config.execution.triageLogBufferMb * 1024 * 1024,
59
+ });
60
+ // Strip ANSI escape codes.
61
+ // eslint-disable-next-line no-control-regex
62
+ const ansiEscapes = /\u001B\[[0-9;]*m/g;
63
+ return stdout.replace(ansiEscapes, "").split("\n").slice(-config.checks.logMaxLines).join("\n");
64
+ } catch {
65
+ return "";
66
+ }
67
+ }
68
+
69
+ function classifyLogs(check: ClassifiedCheck, logs: string): FailureKind {
70
+ // Timed out — check conclusion first, then logs.
71
+ if (check.conclusion === "TIMED_OUT") return "timeout";
72
+ if (TIMEOUT_PATTERNS.some((re) => re.test(logs))) return "timeout";
73
+
74
+ // Infrastructure error — typically CANCELLED with infra markers in logs.
75
+ if (check.conclusion === "CANCELLED" && INFRA_PATTERNS.some((re) => re.test(logs))) {
76
+ return "infrastructure";
77
+ }
78
+
79
+ // No logs at all — treat as infrastructure.
80
+ if (!logs.trim()) return "infrastructure";
81
+
82
+ // Heuristic: if the failure is in a file the PR likely didn't touch
83
+ // and the message contains "flaky" or timing language, call it flaky.
84
+ if (/flaky|timing|race condition|retry/i.test(logs)) return "flaky";
85
+
86
+ // Default: assume actionable.
87
+ return "actionable";
88
+ }
package/src/cli.mts ADDED
@@ -0,0 +1,423 @@
1
+ /**
2
+ * CLI argument parsing and subcommand dispatch for pr-shepherd.
3
+ *
4
+ * Usage:
5
+ * pr-shepherd check [PR] [--format text|json] [--no-cache] [--cache-ttl N]
6
+ * pr-shepherd resolve [PR] [--fetch] [--resolve-thread-ids A,B] [--minimize-comment-ids X,Y]
7
+ * [--dismiss-review-ids Q] [--message MSG] [--require-sha SHA]
8
+ * [--last-push-time N]
9
+ * pr-shepherd iterate [PR] [--cooldown-seconds N] [--ready-delay Nm] [--last-push-time N]
10
+ * pr-shepherd status PR1 [PR2 …]
11
+ */
12
+
13
+ import { runCheck } from "./commands/check.mts";
14
+ import { runResolveFetch, runResolveMutate } from "./commands/resolve.mts";
15
+ import { runIterate } from "./commands/iterate.mts";
16
+ import { runStatus, formatStatusTable } from "./commands/status.mts";
17
+ import { getRepoInfo } from "./github/client.mts";
18
+ import { formatJson } from "./reporters/json.mts";
19
+ import { formatText } from "./reporters/text.mts";
20
+ import { loadConfig } from "./config/load.mts";
21
+ import type { GlobalOptions } from "./types.mts";
22
+
23
+ // Loaded once at startup in main() before any subcommand runs.
24
+ // eslint-disable-next-line prefer-const
25
+ let config: Awaited<ReturnType<typeof loadConfig>>;
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Entry
29
+ // ---------------------------------------------------------------------------
30
+
31
+ export async function main(argv: string[]): Promise<void> {
32
+ config = loadConfig();
33
+
34
+ const args = argv.slice(2); // strip node + script path
35
+
36
+ const subcommand = args[0];
37
+
38
+ switch (subcommand) {
39
+ case "check":
40
+ await handleCheck(args.slice(1));
41
+ break;
42
+ case "resolve":
43
+ await handleResolve(args.slice(1));
44
+ break;
45
+ case "iterate":
46
+ await handleIterate(args.slice(1));
47
+ break;
48
+ case "status":
49
+ await handleStatus(args.slice(1));
50
+ break;
51
+ default:
52
+ process.stderr.write(`Unknown subcommand: ${subcommand ?? "(none)"}\n`);
53
+ process.stderr.write("Usage: pr-shepherd <check|resolve|iterate|status> [options]\n");
54
+ process.exit(1);
55
+ }
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Subcommand handlers
60
+ // ---------------------------------------------------------------------------
61
+
62
+ async function handleCheck(args: string[]): Promise<void> {
63
+ const { prNumber, global: globalOpts } = parseCommonArgs(args);
64
+
65
+ const report = await runCheck({ ...globalOpts, prNumber, autoResolve: false });
66
+ const output = globalOpts.format === "json" ? formatJson(report) : formatText(report);
67
+ process.stdout.write(`${output}\n`);
68
+
69
+ process.exit(statusToExitCode(report.status));
70
+ }
71
+
72
+ async function handleResolve(args: string[]): Promise<void> {
73
+ const { prNumber, global: globalOpts, extra } = parseCommonArgs(args);
74
+
75
+ const resolveThreadIds = parseList(getFlag(extra, "--resolve-thread-ids"));
76
+ const minimizeCommentIds = parseList(getFlag(extra, "--minimize-comment-ids"));
77
+ const dismissReviewIds = parseList(getFlag(extra, "--dismiss-review-ids"));
78
+ const dismissMessage = getFlag(extra, "--message") ?? undefined;
79
+ const requireSha = getFlag(extra, "--require-sha") ?? undefined;
80
+ const fetchMode =
81
+ hasFlag(extra, "--fetch") ||
82
+ (resolveThreadIds.length === 0 &&
83
+ minimizeCommentIds.length === 0 &&
84
+ dismissReviewIds.length === 0);
85
+
86
+ if (fetchMode) {
87
+ const result = await runResolveFetch({ ...globalOpts, prNumber });
88
+ process.stdout.write(
89
+ globalOpts.format === "json"
90
+ ? `${JSON.stringify(result, null, 2)}\n`
91
+ : formatFetchResult(result),
92
+ );
93
+ } else {
94
+ const result = await runResolveMutate({
95
+ ...globalOpts,
96
+ prNumber,
97
+ resolveThreadIds,
98
+ minimizeCommentIds,
99
+ dismissReviewIds,
100
+ dismissMessage,
101
+ requireSha,
102
+ });
103
+ process.stdout.write(
104
+ globalOpts.format === "json"
105
+ ? `${JSON.stringify(result, null, 2)}\n`
106
+ : formatMutateResult(result),
107
+ );
108
+ }
109
+ }
110
+
111
+ async function handleIterate(args: string[]): Promise<void> {
112
+ const { prNumber, global: globalOpts, extra } = parseCommonArgs(args);
113
+
114
+ const lastPushTimeStr = getFlag(extra, "--last-push-time");
115
+ const lastPushTime = lastPushTimeStr ? parseInt(lastPushTimeStr, 10) : undefined;
116
+ const readyDelayStr = getFlag(extra, "--ready-delay");
117
+ const readyDelaySeconds = readyDelayStr
118
+ ? parseDurationToMinutes(readyDelayStr) * 60
119
+ : config.watch.readyDelayMinutes * 60;
120
+ const cooldownSecondsStr = getFlag(extra, "--cooldown-seconds");
121
+ const cooldownSeconds = cooldownSecondsStr
122
+ ? parseInt(cooldownSecondsStr, 10)
123
+ : config.iterate.cooldownSeconds;
124
+ const noAutoRerun = hasFlag(extra, "--no-auto-rerun");
125
+ const noAutoMarkReady = hasFlag(extra, "--no-auto-mark-ready");
126
+ const noAutoCancelActionable = hasFlag(extra, "--no-auto-cancel-actionable");
127
+
128
+ const result = await runIterate({
129
+ ...globalOpts,
130
+ prNumber,
131
+ lastPushTime,
132
+ readyDelaySeconds,
133
+ cooldownSeconds,
134
+ noAutoRerun,
135
+ noAutoMarkReady,
136
+ noAutoCancelActionable,
137
+ });
138
+
139
+ if (globalOpts.format === "json") {
140
+ process.stdout.write(`${JSON.stringify(result)}\n`);
141
+ } else {
142
+ process.stdout.write(`${formatIterateResult(result)}\n`);
143
+ }
144
+
145
+ process.exit(iterateActionToExitCode(result.action));
146
+ }
147
+
148
+ async function handleStatus(args: string[]): Promise<void> {
149
+ const { global: globalOpts } = parseCommonArgs(args);
150
+
151
+ const prNumbers = parseStatusPrNumbers(args);
152
+
153
+ if (prNumbers.length === 0) {
154
+ process.stderr.write("Usage: pr-shepherd status PR1 [PR2 …]\n");
155
+ process.exit(1);
156
+ }
157
+
158
+ const repo = await getRepoInfo();
159
+ const summaries = await runStatus({ ...globalOpts, prNumbers });
160
+ const output =
161
+ globalOpts.format === "json"
162
+ ? JSON.stringify(summaries, null, 2)
163
+ : formatStatusTable(summaries, `${repo.owner}/${repo.name}`);
164
+
165
+ process.stdout.write(`${output}\n`);
166
+
167
+ const allReady = summaries.every((s) => deriveSimpleReady(s));
168
+ process.exit(allReady ? 0 : 1);
169
+ }
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // Argument parsing helpers
173
+ // ---------------------------------------------------------------------------
174
+
175
+ interface ParsedArgs {
176
+ prNumber: number | undefined;
177
+ global: GlobalOptions;
178
+ extra: string[];
179
+ }
180
+
181
+ function parseCommonArgs(args: string[]): ParsedArgs {
182
+ const format = (getFlag(args, "--format") ?? "text") as "text" | "json";
183
+ const noCache = hasFlag(args, "--no-cache");
184
+ const cacheTtlStr = getFlag(args, "--cache-ttl");
185
+ const cacheTtlSeconds = cacheTtlStr ? parseInt(cacheTtlStr, 10) : config.cache.ttlSeconds;
186
+
187
+ // All flags that take a value — used only to prevent their numeric values
188
+ // from being mistaken as the PR number.
189
+ const allFlagsWithValues = new Set([
190
+ "--format",
191
+ "--cache-ttl",
192
+ "--last-push-time",
193
+ "--ready-delay",
194
+ "--cooldown-seconds",
195
+ "--require-sha",
196
+ "--message",
197
+ ]);
198
+ // Only global flags are stripped from `extra`; subcommand-specific flags
199
+ // must remain so handlers like handleIterate/handleResolve can read them.
200
+ const globalFlagsWithValues = new Set(["--format", "--cache-ttl"]);
201
+
202
+ const skipForPrDetect = new Set<number>(); // indices to skip when finding PR number
203
+ const excludeFromExtra = new Set<number>(); // indices to strip from extra (global only)
204
+
205
+ for (let i = 0; i < args.length; i += 1) {
206
+ const arg = args[i]!;
207
+ if (arg === "--no-cache") {
208
+ skipForPrDetect.add(i);
209
+ excludeFromExtra.add(i);
210
+ } else if (globalFlagsWithValues.has(arg)) {
211
+ skipForPrDetect.add(i);
212
+ excludeFromExtra.add(i);
213
+ if (i + 1 < args.length) {
214
+ skipForPrDetect.add(i + 1);
215
+ excludeFromExtra.add(i + 1);
216
+ }
217
+ i += 1;
218
+ } else if (allFlagsWithValues.has(arg)) {
219
+ skipForPrDetect.add(i);
220
+ if (i + 1 < args.length) skipForPrDetect.add(i + 1);
221
+ i += 1;
222
+ } else if ([...allFlagsWithValues].some((f) => arg.startsWith(`${f}=`))) {
223
+ skipForPrDetect.add(i);
224
+ }
225
+ }
226
+
227
+ // First non-skipped positional arg that looks like a PR number.
228
+ const prArg = args.find(
229
+ (a, index) => !skipForPrDetect.has(index) && !a.startsWith("--") && /^\d+$/.test(a),
230
+ );
231
+ const prNumber = prArg ? parseInt(prArg, 10) : undefined;
232
+
233
+ // Only strip global flags from extra — subcommand flags are passed through.
234
+ const extra = args.filter((_, index) => !excludeFromExtra.has(index));
235
+
236
+ return {
237
+ prNumber,
238
+ global: { format, noCache, cacheTtlSeconds },
239
+ extra,
240
+ };
241
+ }
242
+
243
+ /** Get the value of a flag like `--flag value` or `--flag=value`. */
244
+ function getFlag(args: string[], name: string): string | null {
245
+ for (let i = 0; i < args.length; i++) {
246
+ const arg = args[i]!;
247
+ if (arg === name && i + 1 < args.length) return args[i + 1]!;
248
+ if (arg.startsWith(`${name}=`)) return arg.slice(name.length + 1);
249
+ }
250
+ return null;
251
+ }
252
+
253
+ function parseStatusPrNumbers(args: string[]): number[] {
254
+ const flagsWithValues = new Set(["--format", "--cache-ttl"]);
255
+ const prNumbers: number[] = [];
256
+ for (let i = 0; i < args.length; i += 1) {
257
+ const arg = args[i]!;
258
+ if (flagsWithValues.has(arg)) {
259
+ i += 1;
260
+ continue;
261
+ }
262
+ if (arg.startsWith("--")) continue;
263
+ const n = parseInt(arg, 10);
264
+ if (Number.isFinite(n)) prNumbers.push(n);
265
+ }
266
+ return prNumbers;
267
+ }
268
+
269
+ function hasFlag(args: string[], name: string): boolean {
270
+ return args.includes(name);
271
+ }
272
+
273
+ function parseList(value: string | null): string[] {
274
+ if (!value) return [];
275
+ return value
276
+ .split(",")
277
+ .map((s) => s.trim())
278
+ .filter(Boolean);
279
+ }
280
+
281
+ // ---------------------------------------------------------------------------
282
+ // Duration parsing
283
+ // ---------------------------------------------------------------------------
284
+
285
+ function parseDurationToMinutes(s: string): number {
286
+ const m = /^(\d+)(m|min|minutes?|h|hours?)?$/.exec(s.trim());
287
+ if (!m) return config.watch.readyDelayMinutes;
288
+ const n = parseInt(m[1]!, 10);
289
+ const unit = m[2] ?? "m";
290
+ if (unit.startsWith("h")) return n * 60;
291
+ return n;
292
+ }
293
+
294
+ // ---------------------------------------------------------------------------
295
+ // Output formatters
296
+ // ---------------------------------------------------------------------------
297
+
298
+ function formatFetchResult(result: Awaited<ReturnType<typeof runResolveFetch>>): string {
299
+ const lines: string[] = [];
300
+
301
+ if (result.autoResolved.length > 0) {
302
+ lines.push(`Auto-resolved outdated (${result.autoResolved.length}):`);
303
+ for (const t of result.autoResolved) {
304
+ lines.push(` - threadId=${t.id} ${t.path ?? ""}:${t.line ?? "?"} (@${t.author})`);
305
+ }
306
+ }
307
+
308
+ if (result.actionableThreads.length > 0) {
309
+ lines.push(`\nActionable Review Threads (${result.actionableThreads.length}):`);
310
+ for (const t of result.actionableThreads) {
311
+ lines.push(
312
+ ` - threadId=${t.id} ${t.path ?? ""}:${t.line ?? "?"} (@${t.author}): ${t.body.split("\n")[0]?.slice(0, 100) ?? ""}`,
313
+ );
314
+ }
315
+ }
316
+
317
+ if (result.actionableComments.length > 0) {
318
+ lines.push(`\nActionable PR Comments (${result.actionableComments.length}):`);
319
+ for (const c of result.actionableComments) {
320
+ lines.push(
321
+ ` - commentId=${c.id} (@${c.author}): ${c.body.split("\n")[0]?.slice(0, 100) ?? ""}`,
322
+ );
323
+ }
324
+ }
325
+
326
+ if (result.changesRequestedReviews.length > 0) {
327
+ lines.push(`\nPending CHANGES_REQUESTED reviews (${result.changesRequestedReviews.length}):`);
328
+ for (const r of result.changesRequestedReviews) {
329
+ lines.push(` - reviewId=${r.id} (@${r.author})`);
330
+ }
331
+ }
332
+
333
+ const total =
334
+ result.actionableThreads.length +
335
+ result.actionableComments.length +
336
+ result.changesRequestedReviews.length;
337
+ lines.push(
338
+ `\nSummary: ${total === 0 ? "0 actionable — all threads resolved/minimized" : `${total} actionable item(s)`}`,
339
+ );
340
+
341
+ return `${lines.join("\n")}\n`;
342
+ }
343
+
344
+ function formatMutateResult(result: Awaited<ReturnType<typeof runResolveMutate>>): string {
345
+ const lines: string[] = [];
346
+ if (result.resolvedThreads.length)
347
+ lines.push(
348
+ `Resolved threads (${result.resolvedThreads.length}): ${result.resolvedThreads.join(", ")}`,
349
+ );
350
+ if (result.minimizedComments.length)
351
+ lines.push(
352
+ `Minimized comments (${result.minimizedComments.length}): ${result.minimizedComments.join(", ")}`,
353
+ );
354
+ if (result.dismissedReviews.length)
355
+ lines.push(
356
+ `Dismissed reviews (${result.dismissedReviews.length}): ${result.dismissedReviews.join(", ")}`,
357
+ );
358
+ if (result.errors.length) lines.push(`Errors:\n ${result.errors.join("\n ")}`);
359
+ return `${lines.join("\n")}\n`;
360
+ }
361
+
362
+ // ---------------------------------------------------------------------------
363
+ // Exit code mapping
364
+ // ---------------------------------------------------------------------------
365
+
366
+ function statusToExitCode(status: string): number {
367
+ switch (status) {
368
+ case "READY":
369
+ return 0;
370
+ case "IN_PROGRESS":
371
+ return 2;
372
+ case "UNRESOLVED_COMMENTS":
373
+ return 3;
374
+ default:
375
+ return 1;
376
+ }
377
+ }
378
+
379
+ function deriveSimpleReady(s: import("./commands/status.mts").PrSummary): boolean {
380
+ return (
381
+ s.mergeStateStatus === "CLEAN" &&
382
+ s.ciState === "SUCCESS" &&
383
+ s.unresolvedThreads === 0 &&
384
+ s.reviewDecision !== "CHANGES_REQUESTED" &&
385
+ !s.isDraft
386
+ );
387
+ }
388
+
389
+ function formatIterateResult(result: import("./types.mts").IterateResult): string {
390
+ const base = `PR #${result.pr} [${result.action.toUpperCase()}] status=${result.status} merge=${result.mergeStateStatus}`;
391
+ switch (result.action) {
392
+ case "cooldown":
393
+ return `${base} (cooldown: CI still starting)`;
394
+ case "wait":
395
+ return `${base} (${result.remainingSeconds}s until cancel)`;
396
+ case "cancel":
397
+ return `${base} (ready-delay elapsed)`;
398
+ case "fix_code":
399
+ return `${base} threads=${result.fix.threads.length} comments=${result.fix.comments.length} checks=${result.fix.checks.length} cancelled=${result.cancelled.length}`;
400
+ case "rerun_ci":
401
+ return `${base} reran=${result.reran.join(",")}`;
402
+ case "rebase":
403
+ return `${base} (branch is behind main)`;
404
+ case "mark_ready":
405
+ return `${base} markedReady=${result.markedReady}`;
406
+ case "escalate":
407
+ return `${base} triggers=${result.escalate.triggers.join(",")} — ${result.escalate.suggestion}`;
408
+ }
409
+ }
410
+
411
+ function iterateActionToExitCode(action: import("./types.mts").ShepherdAction): number {
412
+ switch (action) {
413
+ case "fix_code":
414
+ case "rebase":
415
+ return 1;
416
+ case "cancel":
417
+ return 2;
418
+ case "escalate":
419
+ return 3;
420
+ default:
421
+ return 0;
422
+ }
423
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * `shepherd check [PR]`
3
+ *
4
+ * Read-only snapshot of PR status. Fetches CI + comments + merge status in
5
+ * one GraphQL request, applies all classifiers, and returns a ShepherdReport.
6
+ *
7
+ * Exit codes:
8
+ * 0 READY — all checks passed, no unresolved threads, CLEAN merge status.
9
+ * 1 FAILING — one or more CI checks failed.
10
+ * 2 IN_PROGRESS — CI checks still running.
11
+ * 3 UNRESOLVED_COMMENTS — CI ok but actionable threads remain.
12
+ * 1 (also) BLOCKED/CONFLICTS/UNKNOWN merge status.
13
+ */
14
+
15
+ import { fetchPrBatch } from "../github/batch.mts";
16
+ import { getRepoInfo, getCurrentPrNumber, getMergeableState } from "../github/client.mts";
17
+ import { cacheGet, cacheSet } from "../cache/file-cache.mts";
18
+ import { classifyChecks, getCiVerdict } from "../checks/classify.mts";
19
+ import { triageFailingChecks } from "../checks/triage.mts";
20
+ import { getOutdatedThreads } from "../comments/outdated.mts";
21
+ import { autoResolveOutdated } from "../comments/resolve.mts";
22
+ import { deriveMergeStatus } from "../merge-status/derive.mts";
23
+ import type {
24
+ GlobalOptions,
25
+ ShepherdReport,
26
+ ShepherdStatus,
27
+ ClassifiedCheck,
28
+ BatchPrData,
29
+ } from "../types.mts";
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Public API
33
+ // ---------------------------------------------------------------------------
34
+
35
+ export interface CheckCommandOptions extends GlobalOptions {
36
+ /** When true, auto-resolve outdated threads. */
37
+ autoResolve?: boolean;
38
+ lastPushTime?: number;
39
+ /** When true, skip fetching logs for failing checks (no failureKind set). */
40
+ skipTriage?: boolean;
41
+ }
42
+
43
+ export async function runCheck(opts: CheckCommandOptions): Promise<ShepherdReport> {
44
+ const repo = await getRepoInfo();
45
+
46
+ const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
47
+ if (prNumber === null) {
48
+ throw new Error("No open PR found for current branch. Pass a PR number explicitly.");
49
+ }
50
+
51
+ const cacheKey = { owner: repo.owner, repo: repo.name, pr: prNumber, shape: "check" };
52
+ // When autoResolve is enabled the command will mutate (resolve threads, minimize
53
+ // comments) — always bypass cache so we act on fresh data, not a stale snapshot.
54
+ const cacheOpts = {
55
+ disabled: opts.noCache || opts.autoResolve,
56
+ ttlSeconds: opts.cacheTtlSeconds,
57
+ };
58
+
59
+ // Try cache first.
60
+ let batchData = await cacheGet<BatchPrData>(cacheKey, cacheOpts);
61
+
62
+ if (batchData === null) {
63
+ const result = await fetchPrBatch(prNumber, repo);
64
+ batchData = result.data;
65
+ // Don't cache UNKNOWN merge state — it's transient and would poison the
66
+ // cache for the full TTL window, causing stale UNKNOWN on the next sweep.
67
+ if (batchData.mergeable !== "UNKNOWN" && batchData.mergeStateStatus !== "UNKNOWN") {
68
+ await cacheSet(cacheKey, batchData, cacheOpts);
69
+ }
70
+ }
71
+
72
+ // GraphQL sometimes returns UNKNOWN for mergeable/mergeStateStatus while the
73
+ // REST API already has the correct value. Fall back to REST in that case.
74
+ // Skip for non-OPEN PRs — REST also returns UNKNOWN for merged/closed PRs.
75
+ if (
76
+ (batchData.state ?? "OPEN") === "OPEN" &&
77
+ (batchData.mergeable === "UNKNOWN" || batchData.mergeStateStatus === "UNKNOWN")
78
+ ) {
79
+ const restState = await getMergeableState(prNumber, repo.owner, repo.name);
80
+ batchData = { ...batchData, ...restState };
81
+ }
82
+
83
+ // Classify checks.
84
+ const classifiedChecks = classifyChecks(batchData.checks);
85
+ const verdict = getCiVerdict(classifiedChecks);
86
+
87
+ const passing = classifiedChecks.filter((c) => c.category === "passed");
88
+ const failing = classifiedChecks.filter((c) => c.category === "failing");
89
+ const inProgress = classifiedChecks.filter((c) => c.category === "in_progress");
90
+ const skipped = classifiedChecks.filter((c) => c.category === "skipped");
91
+ const filtered = classifiedChecks.filter((c) => c.category === "filtered");
92
+
93
+ // Triage failures (fetch logs) — skipped when caller will short-circuit before needing failureKind.
94
+ const triaged =
95
+ failing.length > 0 && !opts.skipTriage ? await triageFailingChecks(failing) : failing;
96
+
97
+ // Resolve threads and comments.
98
+ const unresolvedThreads = batchData.reviewThreads.filter((t) => !t.isResolved);
99
+ const visibleComments = batchData.comments.filter((c) => !c.isMinimized);
100
+
101
+ // Auto-resolve outdated threads.
102
+ const outdated = getOutdatedThreads(unresolvedThreads);
103
+ let autoResolved: typeof outdated = [];
104
+ let autoResolveErrors: string[] = [];
105
+ if (opts.autoResolve && outdated.length > 0) {
106
+ const { resolved: resolvedIds, errors } = await autoResolveOutdated(outdated.map((t) => t.id));
107
+ autoResolved = outdated.filter((t) => resolvedIds.includes(t.id));
108
+ autoResolveErrors = errors;
109
+ }
110
+
111
+ const activeThreads = unresolvedThreads.filter((t) => !t.isOutdated);
112
+
113
+ // Actionable: all active threads and all visible comments (no classification — LLM handles triage).
114
+ const actionableThreads = activeThreads;
115
+ const actionableComments = visibleComments;
116
+
117
+ // Derive merge status.
118
+ const mergeStatus = deriveMergeStatus(batchData);
119
+
120
+ // Derive blockedByFilteredCheck ghost state.
121
+ const blockedByFilteredCheck =
122
+ mergeStatus.status === "BLOCKED" &&
123
+ !verdict.anyFailing &&
124
+ !verdict.anyInProgress &&
125
+ verdict.filteredNames.length > 0;
126
+
127
+ // Compute overall status.
128
+ const status = computeStatus(
129
+ verdict,
130
+ actionableThreads.length,
131
+ actionableComments.length,
132
+ mergeStatus.status,
133
+ batchData.changesRequestedReviews.length,
134
+ );
135
+
136
+ return {
137
+ pr: prNumber,
138
+ repo: `${repo.owner}/${repo.name}`,
139
+ status,
140
+ mergeStatus,
141
+ checks: {
142
+ passing,
143
+ failing: triaged,
144
+ inProgress: inProgress as ClassifiedCheck[],
145
+ skipped,
146
+ filtered,
147
+ filteredNames: verdict.filteredNames,
148
+ blockedByFilteredCheck,
149
+ },
150
+ threads: {
151
+ actionable: actionableThreads,
152
+ autoResolved,
153
+ autoResolveErrors,
154
+ },
155
+ comments: {
156
+ actionable: actionableComments,
157
+ },
158
+ changesRequestedReviews: batchData.changesRequestedReviews,
159
+ lastPushTime: opts.lastPushTime,
160
+ };
161
+ }
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // Helpers
165
+ // ---------------------------------------------------------------------------
166
+
167
+ function computeStatus(
168
+ verdict: ReturnType<typeof getCiVerdict>,
169
+ unresolvedThreads: number,
170
+ unresolvedComments: number,
171
+ mergeStatus: string,
172
+ changesRequestedReviews: number,
173
+ ): ShepherdStatus {
174
+ // Merge conflicts are always terminal regardless of CI state.
175
+ if (mergeStatus === "CONFLICTS") return "FAILING";
176
+ // Check CI state before merge-blocking states: BLOCKED/UNSTABLE/BEHIND are
177
+ // often caused by CI not having passed yet, so they shouldn't mask IN_PROGRESS.
178
+ if (verdict.anyFailing) return "FAILING";
179
+ if (verdict.anyInProgress) return "IN_PROGRESS";
180
+ if (mergeStatus === "BLOCKED" || mergeStatus === "UNSTABLE" || mergeStatus === "BEHIND")
181
+ return "FAILING";
182
+ if (mergeStatus === "UNKNOWN") return "UNKNOWN";
183
+ if (changesRequestedReviews > 0) return "UNRESOLVED_COMMENTS";
184
+ if (unresolvedThreads > 0 || unresolvedComments > 0) return "UNRESOLVED_COMMENTS";
185
+ // DRAFT is treated the same as CLEAN for readiness — marking the PR ready resolves it.
186
+ if ((mergeStatus === "CLEAN" || mergeStatus === "DRAFT") && verdict.allPassed) return "READY";
187
+ return "UNKNOWN";
188
+ }