pr-shepherd 0.2.0 → 0.3.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 +8 -2
- package/README.md +126 -83
- package/dist/cache/file-cache.mjs +78 -0
- package/dist/cache/fix-attempts.mjs +67 -0
- package/dist/checks/classify.mjs +53 -0
- package/dist/checks/triage.mjs +77 -0
- package/dist/cli/args.mjs +153 -0
- package/dist/cli.mjs +203 -0
- package/dist/commands/check.mjs +140 -0
- package/dist/commands/iterate.mjs +295 -0
- package/dist/commands/ready-delay.mjs +87 -0
- package/dist/commands/resolve.mjs +64 -0
- package/dist/commands/status.mjs +107 -0
- package/{src/comments/outdated.mts → dist/comments/outdated.mjs} +2 -5
- package/dist/comments/resolve.mjs +113 -0
- package/dist/config/load.mjs +154 -0
- package/dist/github/batch.mjs +208 -0
- package/dist/github/client.mjs +153 -0
- package/{src/github/pagination.mts → dist/github/pagination.mjs} +26 -52
- package/{src/github/queries.mts → dist/github/queries.mjs} +1 -10
- package/{src/index.mts → dist/index.mjs} +3 -5
- package/dist/merge-status/derive.mjs +72 -0
- package/dist/reporters/agent.mjs +41 -0
- package/{src/reporters/json.mts → dist/reporters/json.mjs} +2 -5
- package/dist/reporters/text.mjs +111 -0
- package/dist/types.mjs +2 -0
- package/package.json +6 -6
- package/skills/check/SKILL.md +1 -1
- package/skills/monitor/SKILL.md +9 -5
- package/src/cache/file-cache.mts +0 -101
- package/src/cache/file-cache.test.mts +0 -91
- package/src/cache/fix-attempts.mts +0 -86
- package/src/checks/classify.mts +0 -80
- package/src/checks/classify.test.mts +0 -164
- package/src/checks/triage.mock.test.mts +0 -202
- package/src/checks/triage.mts +0 -88
- package/src/cli.mts +0 -423
- package/src/commands/check.mts +0 -188
- package/src/commands/iterate.mock.test.mts +0 -1111
- package/src/commands/iterate.mts +0 -371
- package/src/commands/ready-delay.mts +0 -117
- package/src/commands/ready-delay.test.mts +0 -116
- package/src/commands/resolve.mts +0 -92
- package/src/commands/status.mts +0 -173
- package/src/comments/resolve.mts +0 -179
- package/src/config/load.mts +0 -240
- package/src/github/batch.mts +0 -351
- package/src/github/client.mts +0 -207
- package/src/github/client.test.mts +0 -19
- package/src/github/pagination.test.mts +0 -140
- package/src/merge-status/derive.mts +0 -74
- package/src/merge-status/derive.test.mts +0 -130
- package/src/reporters/text.mts +0 -140
- package/src/types.mts +0 -309
- /package/{src → dist}/config.json +0 -0
- /package/{src → dist}/github/gql/batch-pr.gql +0 -0
- /package/{src → dist}/github/gql/dismiss-review.gql +0 -0
- /package/{src → dist}/github/gql/minimize-comment.gql +0 -0
- /package/{src → dist}/github/gql/multi-pr-status-paged.gql +0 -0
- /package/{src → dist}/github/gql/multi-pr-status.gql +0 -0
- /package/{src → dist}/github/gql/resolve-thread.gql +0 -0
- /package/{src/util/path-segment.mts → dist/util/path-segment.mjs} +0 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI argument-parsing helpers extracted from cli.mts for testability.
|
|
3
|
+
* Note: parseCommonArgs calls loadConfig() for cache TTL defaults.
|
|
4
|
+
*/
|
|
5
|
+
import { loadConfig } from "../config/load.mjs";
|
|
6
|
+
import { deriveVerdict } from "../commands/status.mjs";
|
|
7
|
+
// Flags that consume the next argument as their value.
|
|
8
|
+
const FLAGS_WITH_VALUES = new Set([
|
|
9
|
+
"--format",
|
|
10
|
+
"--cache-ttl",
|
|
11
|
+
"--last-push-time",
|
|
12
|
+
"--ready-delay",
|
|
13
|
+
"--cooldown-seconds",
|
|
14
|
+
"--require-sha",
|
|
15
|
+
"--message",
|
|
16
|
+
]);
|
|
17
|
+
export function parseCommonArgs(args) {
|
|
18
|
+
const config = loadConfig();
|
|
19
|
+
const format = (getFlag(args, "--format") ?? "text");
|
|
20
|
+
const noCache = hasFlag(args, "--no-cache");
|
|
21
|
+
const cacheTtlStr = getFlag(args, "--cache-ttl");
|
|
22
|
+
const cacheTtlSeconds = cacheTtlStr ? parseInt(cacheTtlStr, 10) : config.cache.ttlSeconds;
|
|
23
|
+
// Only global flags are stripped from `extra`; subcommand-specific flags
|
|
24
|
+
// must remain so handlers like handleIterate/handleResolve can read them.
|
|
25
|
+
const globalFlagsWithValues = new Set(["--format", "--cache-ttl"]);
|
|
26
|
+
const skipForPrDetect = new Set(); // indices to skip when finding PR number
|
|
27
|
+
const excludeFromExtra = new Set(); // indices to strip from extra (global only)
|
|
28
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
29
|
+
const arg = args[i];
|
|
30
|
+
if (arg === "--no-cache") {
|
|
31
|
+
skipForPrDetect.add(i);
|
|
32
|
+
excludeFromExtra.add(i);
|
|
33
|
+
}
|
|
34
|
+
else if (globalFlagsWithValues.has(arg)) {
|
|
35
|
+
skipForPrDetect.add(i);
|
|
36
|
+
excludeFromExtra.add(i);
|
|
37
|
+
if (i + 1 < args.length) {
|
|
38
|
+
skipForPrDetect.add(i + 1);
|
|
39
|
+
excludeFromExtra.add(i + 1);
|
|
40
|
+
}
|
|
41
|
+
i += 1;
|
|
42
|
+
}
|
|
43
|
+
else if (FLAGS_WITH_VALUES.has(arg)) {
|
|
44
|
+
skipForPrDetect.add(i);
|
|
45
|
+
if (i + 1 < args.length)
|
|
46
|
+
skipForPrDetect.add(i + 1);
|
|
47
|
+
i += 1;
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
const eqIdx = arg.indexOf("=");
|
|
51
|
+
if (eqIdx > 0) {
|
|
52
|
+
const flagName = arg.slice(0, eqIdx);
|
|
53
|
+
if (FLAGS_WITH_VALUES.has(flagName)) {
|
|
54
|
+
skipForPrDetect.add(i);
|
|
55
|
+
if (globalFlagsWithValues.has(flagName))
|
|
56
|
+
excludeFromExtra.add(i);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// First non-skipped positional arg that looks like a PR number.
|
|
62
|
+
const prArg = args.find((a, index) => !skipForPrDetect.has(index) && !a.startsWith("--") && /^\d+$/.test(a));
|
|
63
|
+
const prNumber = prArg ? parseInt(prArg, 10) : undefined;
|
|
64
|
+
// Only strip global flags from extra — subcommand flags are passed through.
|
|
65
|
+
const extra = args.filter((_, index) => !excludeFromExtra.has(index));
|
|
66
|
+
return {
|
|
67
|
+
prNumber,
|
|
68
|
+
global: { format, noCache, cacheTtlSeconds },
|
|
69
|
+
extra,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/** Get the value of a flag like `--flag value` or `--flag=value`. */
|
|
73
|
+
export function getFlag(args, name) {
|
|
74
|
+
for (let i = 0; i < args.length; i++) {
|
|
75
|
+
const arg = args[i];
|
|
76
|
+
if (arg === name && i + 1 < args.length)
|
|
77
|
+
return args[i + 1];
|
|
78
|
+
if (arg.startsWith(`${name}=`))
|
|
79
|
+
return arg.slice(name.length + 1);
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
export function hasFlag(args, name) {
|
|
84
|
+
return args.includes(name);
|
|
85
|
+
}
|
|
86
|
+
export function parseList(value) {
|
|
87
|
+
if (!value)
|
|
88
|
+
return [];
|
|
89
|
+
return value
|
|
90
|
+
.split(",")
|
|
91
|
+
.map((s) => s.trim())
|
|
92
|
+
.filter(Boolean);
|
|
93
|
+
}
|
|
94
|
+
export function parseStatusPrNumbers(args) {
|
|
95
|
+
const prNumbers = [];
|
|
96
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
97
|
+
const arg = args[i];
|
|
98
|
+
if (FLAGS_WITH_VALUES.has(arg)) {
|
|
99
|
+
i += 1;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (arg.startsWith("--"))
|
|
103
|
+
continue;
|
|
104
|
+
const n = parseInt(arg, 10);
|
|
105
|
+
if (Number.isFinite(n))
|
|
106
|
+
prNumbers.push(n);
|
|
107
|
+
}
|
|
108
|
+
return prNumbers;
|
|
109
|
+
}
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// Duration parsing
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
export function parseDurationToMinutes(s, defaultMinutes) {
|
|
114
|
+
const m = /^(\d+)(m|min|minutes?|h|hours?)?$/.exec(s.trim());
|
|
115
|
+
if (!m)
|
|
116
|
+
return defaultMinutes ?? loadConfig().watch.readyDelayMinutes;
|
|
117
|
+
const n = parseInt(m[1], 10);
|
|
118
|
+
const unit = m[2] ?? "m";
|
|
119
|
+
if (unit.startsWith("h"))
|
|
120
|
+
return n * 60;
|
|
121
|
+
return n;
|
|
122
|
+
}
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// Exit code mapping
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
export function statusToExitCode(status) {
|
|
127
|
+
switch (status) {
|
|
128
|
+
case "READY":
|
|
129
|
+
return 0;
|
|
130
|
+
case "IN_PROGRESS":
|
|
131
|
+
return 2;
|
|
132
|
+
case "UNRESOLVED_COMMENTS":
|
|
133
|
+
return 3;
|
|
134
|
+
default:
|
|
135
|
+
return 1;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
export function iterateActionToExitCode(action) {
|
|
139
|
+
switch (action) {
|
|
140
|
+
case "fix_code":
|
|
141
|
+
case "rebase":
|
|
142
|
+
return 1;
|
|
143
|
+
case "cancel":
|
|
144
|
+
return 2;
|
|
145
|
+
case "escalate":
|
|
146
|
+
return 3;
|
|
147
|
+
default:
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
export function deriveSimpleReady(s) {
|
|
152
|
+
return deriveVerdict(s) === "READY";
|
|
153
|
+
}
|
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
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
|
+
import { runCheck } from "./commands/check.mjs";
|
|
13
|
+
import { runResolveFetch, runResolveMutate } from "./commands/resolve.mjs";
|
|
14
|
+
import { runIterate } from "./commands/iterate.mjs";
|
|
15
|
+
import { runStatus, formatStatusTable } from "./commands/status.mjs";
|
|
16
|
+
import { getRepoInfo } from "./github/client.mjs";
|
|
17
|
+
import { formatJson } from "./reporters/json.mjs";
|
|
18
|
+
import { formatText } from "./reporters/text.mjs";
|
|
19
|
+
import { loadConfig } from "./config/load.mjs";
|
|
20
|
+
import { parseCommonArgs, getFlag, hasFlag, parseList, parseStatusPrNumbers, parseDurationToMinutes, statusToExitCode, iterateActionToExitCode, deriveSimpleReady, } from "./cli/args.mjs";
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Entry
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
export async function main(argv) {
|
|
25
|
+
const args = argv.slice(2); // strip node + script path
|
|
26
|
+
const subcommand = args[0];
|
|
27
|
+
switch (subcommand) {
|
|
28
|
+
case "check":
|
|
29
|
+
await handleCheck(args.slice(1));
|
|
30
|
+
break;
|
|
31
|
+
case "resolve":
|
|
32
|
+
await handleResolve(args.slice(1));
|
|
33
|
+
break;
|
|
34
|
+
case "iterate":
|
|
35
|
+
await handleIterate(args.slice(1));
|
|
36
|
+
break;
|
|
37
|
+
case "status":
|
|
38
|
+
await handleStatus(args.slice(1));
|
|
39
|
+
break;
|
|
40
|
+
default:
|
|
41
|
+
process.stderr.write(`Unknown subcommand: ${subcommand ?? "(none)"}\n`);
|
|
42
|
+
process.stderr.write("Usage: pr-shepherd <check|resolve|iterate|status> [options]\n");
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Subcommand handlers
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
async function handleCheck(args) {
|
|
50
|
+
const { prNumber, global: globalOpts } = parseCommonArgs(args);
|
|
51
|
+
const report = await runCheck({ ...globalOpts, prNumber, autoResolve: false });
|
|
52
|
+
const output = globalOpts.format === "json" ? formatJson(report) : formatText(report);
|
|
53
|
+
process.stdout.write(`${output}\n`);
|
|
54
|
+
process.exit(statusToExitCode(report.status));
|
|
55
|
+
}
|
|
56
|
+
async function handleResolve(args) {
|
|
57
|
+
const { prNumber, global: globalOpts, extra } = parseCommonArgs(args);
|
|
58
|
+
const resolveThreadIds = parseList(getFlag(extra, "--resolve-thread-ids"));
|
|
59
|
+
const minimizeCommentIds = parseList(getFlag(extra, "--minimize-comment-ids"));
|
|
60
|
+
const dismissReviewIds = parseList(getFlag(extra, "--dismiss-review-ids"));
|
|
61
|
+
const dismissMessage = getFlag(extra, "--message") ?? undefined;
|
|
62
|
+
const requireSha = getFlag(extra, "--require-sha") ?? undefined;
|
|
63
|
+
const fetchMode = hasFlag(extra, "--fetch") ||
|
|
64
|
+
(resolveThreadIds.length === 0 &&
|
|
65
|
+
minimizeCommentIds.length === 0 &&
|
|
66
|
+
dismissReviewIds.length === 0);
|
|
67
|
+
if (fetchMode) {
|
|
68
|
+
const result = await runResolveFetch({ ...globalOpts, prNumber });
|
|
69
|
+
process.stdout.write(globalOpts.format === "json"
|
|
70
|
+
? `${JSON.stringify(result, null, 2)}\n`
|
|
71
|
+
: formatFetchResult(result));
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
const result = await runResolveMutate({
|
|
75
|
+
...globalOpts,
|
|
76
|
+
prNumber,
|
|
77
|
+
resolveThreadIds,
|
|
78
|
+
minimizeCommentIds,
|
|
79
|
+
dismissReviewIds,
|
|
80
|
+
dismissMessage,
|
|
81
|
+
requireSha,
|
|
82
|
+
});
|
|
83
|
+
process.stdout.write(globalOpts.format === "json"
|
|
84
|
+
? `${JSON.stringify(result, null, 2)}\n`
|
|
85
|
+
: formatMutateResult(result));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
async function handleIterate(args) {
|
|
89
|
+
const { prNumber, global: globalOpts, extra } = parseCommonArgs(args);
|
|
90
|
+
const lastPushTimeStr = getFlag(extra, "--last-push-time");
|
|
91
|
+
const lastPushTime = lastPushTimeStr ? parseInt(lastPushTimeStr, 10) : undefined;
|
|
92
|
+
const readyDelayStr = getFlag(extra, "--ready-delay");
|
|
93
|
+
const cfg = loadConfig();
|
|
94
|
+
const readyDelaySeconds = parseDurationToMinutes(readyDelayStr ?? "", cfg.watch.readyDelayMinutes) * 60;
|
|
95
|
+
const cooldownSecondsStr = getFlag(extra, "--cooldown-seconds");
|
|
96
|
+
const cooldownSeconds = cooldownSecondsStr
|
|
97
|
+
? parseInt(cooldownSecondsStr, 10)
|
|
98
|
+
: cfg.iterate.cooldownSeconds;
|
|
99
|
+
const noAutoRerun = hasFlag(extra, "--no-auto-rerun");
|
|
100
|
+
const noAutoMarkReady = hasFlag(extra, "--no-auto-mark-ready");
|
|
101
|
+
const noAutoCancelActionable = hasFlag(extra, "--no-auto-cancel-actionable");
|
|
102
|
+
const result = await runIterate({
|
|
103
|
+
...globalOpts,
|
|
104
|
+
prNumber,
|
|
105
|
+
lastPushTime,
|
|
106
|
+
readyDelaySeconds,
|
|
107
|
+
cooldownSeconds,
|
|
108
|
+
noAutoRerun,
|
|
109
|
+
noAutoMarkReady,
|
|
110
|
+
noAutoCancelActionable,
|
|
111
|
+
});
|
|
112
|
+
if (globalOpts.format === "json") {
|
|
113
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
process.stdout.write(`${formatIterateResult(result)}\n`);
|
|
117
|
+
}
|
|
118
|
+
process.exit(iterateActionToExitCode(result.action));
|
|
119
|
+
}
|
|
120
|
+
async function handleStatus(args) {
|
|
121
|
+
const { global: globalOpts } = parseCommonArgs(args);
|
|
122
|
+
const prNumbers = parseStatusPrNumbers(args);
|
|
123
|
+
if (prNumbers.length === 0) {
|
|
124
|
+
process.stderr.write("Usage: pr-shepherd status PR1 [PR2 …]\n");
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
const repo = await getRepoInfo();
|
|
128
|
+
const summaries = await runStatus({ ...globalOpts, prNumbers });
|
|
129
|
+
const output = globalOpts.format === "json"
|
|
130
|
+
? JSON.stringify(summaries, null, 2)
|
|
131
|
+
: formatStatusTable(summaries, `${repo.owner}/${repo.name}`);
|
|
132
|
+
process.stdout.write(`${output}\n`);
|
|
133
|
+
const allReady = summaries.every((s) => deriveSimpleReady(s));
|
|
134
|
+
process.exit(allReady ? 0 : 1);
|
|
135
|
+
}
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Output formatters
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
function formatFetchResult(result) {
|
|
140
|
+
const lines = [];
|
|
141
|
+
if (result.autoResolved.length > 0) {
|
|
142
|
+
lines.push(`Auto-resolved outdated (${result.autoResolved.length}):`);
|
|
143
|
+
for (const t of result.autoResolved) {
|
|
144
|
+
lines.push(` - threadId=${t.id} ${t.path ?? ""}:${t.line ?? "?"} (@${t.author})`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (result.actionableThreads.length > 0) {
|
|
148
|
+
lines.push(`\nActionable Review Threads (${result.actionableThreads.length}):`);
|
|
149
|
+
for (const t of result.actionableThreads) {
|
|
150
|
+
lines.push(` - threadId=${t.id} ${t.path ?? ""}:${t.line ?? "?"} (@${t.author}): ${t.body.split("\n")[0]?.slice(0, 100) ?? ""}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (result.actionableComments.length > 0) {
|
|
154
|
+
lines.push(`\nActionable PR Comments (${result.actionableComments.length}):`);
|
|
155
|
+
for (const c of result.actionableComments) {
|
|
156
|
+
lines.push(` - commentId=${c.id} (@${c.author}): ${c.body.split("\n")[0]?.slice(0, 100) ?? ""}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (result.changesRequestedReviews.length > 0) {
|
|
160
|
+
lines.push(`\nPending CHANGES_REQUESTED reviews (${result.changesRequestedReviews.length}):`);
|
|
161
|
+
for (const r of result.changesRequestedReviews) {
|
|
162
|
+
lines.push(` - reviewId=${r.id} (@${r.author})`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const total = result.actionableThreads.length +
|
|
166
|
+
result.actionableComments.length +
|
|
167
|
+
result.changesRequestedReviews.length;
|
|
168
|
+
lines.push(`\nSummary: ${total === 0 ? "0 actionable — all threads resolved/minimized" : `${total} actionable item(s)`}`);
|
|
169
|
+
return `${lines.join("\n")}\n`;
|
|
170
|
+
}
|
|
171
|
+
function formatMutateResult(result) {
|
|
172
|
+
const lines = [];
|
|
173
|
+
if (result.resolvedThreads.length)
|
|
174
|
+
lines.push(`Resolved threads (${result.resolvedThreads.length}): ${result.resolvedThreads.join(", ")}`);
|
|
175
|
+
if (result.minimizedComments.length)
|
|
176
|
+
lines.push(`Minimized comments (${result.minimizedComments.length}): ${result.minimizedComments.join(", ")}`);
|
|
177
|
+
if (result.dismissedReviews.length)
|
|
178
|
+
lines.push(`Dismissed reviews (${result.dismissedReviews.length}): ${result.dismissedReviews.join(", ")}`);
|
|
179
|
+
if (result.errors.length)
|
|
180
|
+
lines.push(`Errors:\n ${result.errors.join("\n ")}`);
|
|
181
|
+
return `${lines.join("\n")}\n`;
|
|
182
|
+
}
|
|
183
|
+
function formatIterateResult(result) {
|
|
184
|
+
const base = `PR #${result.pr} [${result.action.toUpperCase()}] status=${result.status} merge=${result.mergeStateStatus}`;
|
|
185
|
+
switch (result.action) {
|
|
186
|
+
case "cooldown":
|
|
187
|
+
return `${base} (cooldown: CI still starting)`;
|
|
188
|
+
case "wait":
|
|
189
|
+
return `${base} (${result.remainingSeconds}s until cancel)`;
|
|
190
|
+
case "cancel":
|
|
191
|
+
return `${base} (ready-delay elapsed)`;
|
|
192
|
+
case "fix_code":
|
|
193
|
+
return `${base} threads=${result.fix.threads.length} comments=${result.fix.comments.length} checks=${result.fix.checks.length} cancelled=${result.cancelled.length}`;
|
|
194
|
+
case "rerun_ci":
|
|
195
|
+
return `${base} reran=${result.reran.join(",")}`;
|
|
196
|
+
case "rebase":
|
|
197
|
+
return `${base} (branch is behind main)`;
|
|
198
|
+
case "mark_ready":
|
|
199
|
+
return `${base} markedReady=${result.markedReady}`;
|
|
200
|
+
case "escalate":
|
|
201
|
+
return `${base} triggers=${result.escalate.triggers.join(",")} — ${result.escalate.suggestion}`;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
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
|
+
import { fetchPrBatch } from "../github/batch.mjs";
|
|
15
|
+
import { getRepoInfo, getCurrentPrNumber, getMergeableState } from "../github/client.mjs";
|
|
16
|
+
import { cacheGet, cacheSet } from "../cache/file-cache.mjs";
|
|
17
|
+
import { classifyChecks, getCiVerdict } from "../checks/classify.mjs";
|
|
18
|
+
import { triageFailingChecks } from "../checks/triage.mjs";
|
|
19
|
+
import { getOutdatedThreads } from "../comments/outdated.mjs";
|
|
20
|
+
import { autoResolveOutdated } from "../comments/resolve.mjs";
|
|
21
|
+
import { deriveMergeStatus } from "../merge-status/derive.mjs";
|
|
22
|
+
export async function runCheck(opts) {
|
|
23
|
+
const repo = await getRepoInfo();
|
|
24
|
+
const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
|
|
25
|
+
if (prNumber === null) {
|
|
26
|
+
throw new Error("No open PR found for current branch. Pass a PR number explicitly.");
|
|
27
|
+
}
|
|
28
|
+
const cacheKey = { owner: repo.owner, repo: repo.name, pr: prNumber, shape: "check" };
|
|
29
|
+
// When autoResolve is enabled the command will mutate (resolve threads, minimize
|
|
30
|
+
// comments) — always bypass cache so we act on fresh data, not a stale snapshot.
|
|
31
|
+
const cacheOpts = {
|
|
32
|
+
disabled: opts.noCache || opts.autoResolve,
|
|
33
|
+
ttlSeconds: opts.cacheTtlSeconds,
|
|
34
|
+
};
|
|
35
|
+
// Try cache first.
|
|
36
|
+
let batchData = await cacheGet(cacheKey, cacheOpts);
|
|
37
|
+
if (batchData === null) {
|
|
38
|
+
const result = await fetchPrBatch(prNumber, repo);
|
|
39
|
+
batchData = result.data;
|
|
40
|
+
// Don't cache UNKNOWN merge state — it's transient and would poison the
|
|
41
|
+
// cache for the full TTL window, causing stale UNKNOWN on the next sweep.
|
|
42
|
+
if (batchData.mergeable !== "UNKNOWN" && batchData.mergeStateStatus !== "UNKNOWN") {
|
|
43
|
+
await cacheSet(cacheKey, batchData, cacheOpts);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// GraphQL sometimes returns UNKNOWN for mergeable/mergeStateStatus while the
|
|
47
|
+
// REST API already has the correct value. Fall back to REST in that case.
|
|
48
|
+
// Skip for non-OPEN PRs — REST also returns UNKNOWN for merged/closed PRs.
|
|
49
|
+
if ((batchData.state ?? "OPEN") === "OPEN" &&
|
|
50
|
+
(batchData.mergeable === "UNKNOWN" || batchData.mergeStateStatus === "UNKNOWN")) {
|
|
51
|
+
const restState = await getMergeableState(prNumber, repo.owner, repo.name);
|
|
52
|
+
batchData = { ...batchData, ...restState };
|
|
53
|
+
}
|
|
54
|
+
// Classify checks.
|
|
55
|
+
const classifiedChecks = classifyChecks(batchData.checks);
|
|
56
|
+
const verdict = getCiVerdict(classifiedChecks);
|
|
57
|
+
const passing = classifiedChecks.filter((c) => c.category === "passed");
|
|
58
|
+
const failing = classifiedChecks.filter((c) => c.category === "failing");
|
|
59
|
+
const inProgress = classifiedChecks.filter((c) => c.category === "in_progress");
|
|
60
|
+
const skipped = classifiedChecks.filter((c) => c.category === "skipped");
|
|
61
|
+
const filtered = classifiedChecks.filter((c) => c.category === "filtered");
|
|
62
|
+
// Triage failures (fetch logs) — skipped when caller will short-circuit before needing failureKind.
|
|
63
|
+
const triaged = failing.length > 0 && !opts.skipTriage ? await triageFailingChecks(failing) : failing;
|
|
64
|
+
// Resolve threads and comments.
|
|
65
|
+
const unresolvedThreads = batchData.reviewThreads.filter((t) => !t.isResolved);
|
|
66
|
+
const visibleComments = batchData.comments.filter((c) => !c.isMinimized);
|
|
67
|
+
// Auto-resolve outdated threads.
|
|
68
|
+
const outdated = getOutdatedThreads(unresolvedThreads);
|
|
69
|
+
let autoResolved = [];
|
|
70
|
+
let autoResolveErrors = [];
|
|
71
|
+
if (opts.autoResolve && outdated.length > 0) {
|
|
72
|
+
const { resolved: resolvedIds, errors } = await autoResolveOutdated(outdated.map((t) => t.id));
|
|
73
|
+
autoResolved = outdated.filter((t) => resolvedIds.includes(t.id));
|
|
74
|
+
autoResolveErrors = errors;
|
|
75
|
+
}
|
|
76
|
+
const activeThreads = unresolvedThreads.filter((t) => !t.isOutdated);
|
|
77
|
+
// Actionable: all active threads and all visible comments (no classification — LLM handles triage).
|
|
78
|
+
const actionableThreads = activeThreads;
|
|
79
|
+
const actionableComments = visibleComments;
|
|
80
|
+
// Derive merge status.
|
|
81
|
+
const mergeStatus = deriveMergeStatus(batchData);
|
|
82
|
+
// Derive blockedByFilteredCheck ghost state.
|
|
83
|
+
const blockedByFilteredCheck = mergeStatus.status === "BLOCKED" &&
|
|
84
|
+
!verdict.anyFailing &&
|
|
85
|
+
!verdict.anyInProgress &&
|
|
86
|
+
verdict.filteredNames.length > 0;
|
|
87
|
+
// Compute overall status.
|
|
88
|
+
const status = computeStatus(verdict, actionableThreads.length, actionableComments.length, mergeStatus.status, batchData.changesRequestedReviews.length);
|
|
89
|
+
return {
|
|
90
|
+
pr: prNumber,
|
|
91
|
+
repo: `${repo.owner}/${repo.name}`,
|
|
92
|
+
status,
|
|
93
|
+
mergeStatus,
|
|
94
|
+
checks: {
|
|
95
|
+
passing,
|
|
96
|
+
failing: triaged,
|
|
97
|
+
inProgress: inProgress,
|
|
98
|
+
skipped,
|
|
99
|
+
filtered,
|
|
100
|
+
filteredNames: verdict.filteredNames,
|
|
101
|
+
blockedByFilteredCheck,
|
|
102
|
+
},
|
|
103
|
+
threads: {
|
|
104
|
+
actionable: actionableThreads,
|
|
105
|
+
autoResolved,
|
|
106
|
+
autoResolveErrors,
|
|
107
|
+
},
|
|
108
|
+
comments: {
|
|
109
|
+
actionable: actionableComments,
|
|
110
|
+
},
|
|
111
|
+
changesRequestedReviews: batchData.changesRequestedReviews,
|
|
112
|
+
lastPushTime: opts.lastPushTime,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Helpers
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
function computeStatus(verdict, unresolvedThreads, unresolvedComments, mergeStatus, changesRequestedReviews) {
|
|
119
|
+
// Merge conflicts are always terminal regardless of CI state.
|
|
120
|
+
if (mergeStatus === "CONFLICTS")
|
|
121
|
+
return "FAILING";
|
|
122
|
+
// Check CI state before merge-blocking states: BLOCKED/UNSTABLE/BEHIND are
|
|
123
|
+
// often caused by CI not having passed yet, so they shouldn't mask IN_PROGRESS.
|
|
124
|
+
if (verdict.anyFailing)
|
|
125
|
+
return "FAILING";
|
|
126
|
+
if (verdict.anyInProgress)
|
|
127
|
+
return "IN_PROGRESS";
|
|
128
|
+
if (mergeStatus === "BLOCKED" || mergeStatus === "UNSTABLE" || mergeStatus === "BEHIND")
|
|
129
|
+
return "FAILING";
|
|
130
|
+
if (mergeStatus === "UNKNOWN")
|
|
131
|
+
return "UNKNOWN";
|
|
132
|
+
if (changesRequestedReviews > 0)
|
|
133
|
+
return "UNRESOLVED_COMMENTS";
|
|
134
|
+
if (unresolvedThreads > 0 || unresolvedComments > 0)
|
|
135
|
+
return "UNRESOLVED_COMMENTS";
|
|
136
|
+
// DRAFT is treated the same as CLEAN for readiness — marking the PR ready resolves it.
|
|
137
|
+
if ((mergeStatus === "CLEAN" || mergeStatus === "DRAFT") && verdict.allPassed)
|
|
138
|
+
return "READY";
|
|
139
|
+
return "UNKNOWN";
|
|
140
|
+
}
|