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.
- package/.claude-plugin/plugin.json +14 -0
- package/LICENSE +21 -0
- package/README.md +154 -0
- package/marketplace.json +8 -0
- package/package.json +62 -0
- package/skills/check/SKILL.md +70 -0
- package/skills/monitor/SKILL.md +108 -0
- package/skills/resolve/SKILL.md +85 -0
- package/src/cache/file-cache.mts +101 -0
- package/src/cache/file-cache.test.mts +91 -0
- package/src/cache/fix-attempts.mts +86 -0
- package/src/checks/classify.mts +80 -0
- package/src/checks/classify.test.mts +164 -0
- package/src/checks/triage.mock.test.mts +202 -0
- package/src/checks/triage.mts +88 -0
- package/src/cli.mts +423 -0
- package/src/commands/check.mts +188 -0
- package/src/commands/iterate.mock.test.mts +1111 -0
- package/src/commands/iterate.mts +371 -0
- package/src/commands/ready-delay.mts +117 -0
- package/src/commands/ready-delay.test.mts +116 -0
- package/src/commands/resolve.mts +92 -0
- package/src/commands/status.mts +173 -0
- package/src/comments/outdated.mts +18 -0
- package/src/comments/resolve.mts +179 -0
- package/src/config/load.mts +240 -0
- package/src/config.json +52 -0
- package/src/github/batch.mts +351 -0
- package/src/github/client.mts +207 -0
- package/src/github/client.test.mts +19 -0
- package/src/github/gql/batch-pr.gql +130 -0
- package/src/github/gql/dismiss-review.gql +7 -0
- package/src/github/gql/minimize-comment.gql +7 -0
- package/src/github/gql/multi-pr-status-paged.gql +31 -0
- package/src/github/gql/multi-pr-status.gql +32 -0
- package/src/github/gql/resolve-thread.gql +7 -0
- package/src/github/pagination.mts +86 -0
- package/src/github/pagination.test.mts +140 -0
- package/src/github/queries.mts +30 -0
- package/src/index.mts +17 -0
- package/src/merge-status/derive.mts +74 -0
- package/src/merge-status/derive.test.mts +130 -0
- package/src/reporters/json.mts +12 -0
- package/src/reporters/text.mts +140 -0
- package/src/types.mts +309 -0
- 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
|
+
}
|