pr-shepherd 0.7.0 → 0.8.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 +1 -1
- package/README.md +37 -302
- package/bin/checks/classify.mjs +5 -4
- package/bin/checks/triage.mjs +76 -62
- package/bin/cli/args.mjs +29 -61
- package/bin/cli/exit-codes.mjs +39 -0
- package/bin/cli/fix-formatter.mjs +76 -0
- package/bin/cli/formatters.mjs +108 -0
- package/bin/cli/handlers.mjs +138 -0
- package/bin/cli/iterate-formatter.mjs +78 -0
- package/bin/cli-parser.iterate-fixtures.mjs +65 -0
- package/bin/cli-parser.mjs +110 -0
- package/bin/commands/check-status.mjs +35 -0
- package/bin/commands/check.mjs +14 -61
- package/bin/commands/commit-suggestion.mjs +159 -0
- package/bin/commands/iterate/classify.mjs +77 -0
- package/bin/commands/iterate/escalate.mjs +124 -0
- package/bin/commands/iterate/fix-code.mjs +97 -0
- package/bin/commands/iterate/helpers.mjs +103 -0
- package/bin/commands/iterate/index.mjs +122 -0
- package/bin/commands/iterate/render.mjs +119 -0
- package/bin/commands/iterate/stall.mjs +65 -0
- package/bin/commands/iterate/steps.mjs +31 -0
- package/bin/commands/iterate.mjs +2 -628
- package/bin/commands/monitor.mjs +78 -0
- package/bin/commands/ready-delay.mjs +3 -4
- package/bin/commands/resolve-instructions.mjs +39 -0
- package/bin/commands/resolve.mjs +34 -3
- package/bin/commands/status.mjs +7 -0
- package/bin/comments/resolve.mjs +1 -1
- package/bin/config/load.mjs +17 -113
- package/bin/config.json +10 -22
- package/bin/github/batch-parsers.mjs +140 -0
- package/bin/github/batch-raw-types.mjs +2 -0
- package/bin/github/batch.mjs +34 -129
- package/bin/github/client.mjs +47 -9
- package/bin/github/gql/batch-pr.gql +20 -0
- package/bin/github/http.mjs +32 -30
- package/bin/index.mjs +15 -2
- package/bin/merge-status/derive.mjs +11 -11
- package/bin/reporters/agent.mjs +13 -4
- package/bin/reporters/check-instructions.mjs +65 -0
- package/bin/reporters/json.mjs +3 -2
- package/bin/reporters/text.mjs +108 -61
- package/bin/{cache → state}/fix-attempts.mjs +3 -3
- package/bin/state/iterate-stall.mjs +74 -0
- package/bin/suggestions/parse.mjs +119 -0
- package/bin/suggestions/patch.mjs +52 -0
- package/bin/types/github.mjs +2 -0
- package/bin/types/iterate.mjs +2 -0
- package/bin/types/report.mjs +2 -0
- package/bin/types.mjs +3 -1
- package/package.json +3 -3
- package/plugin/skills/check/SKILL.md +15 -48
- package/plugin/skills/monitor/SKILL.md +11 -64
- package/plugin/skills/resolve/SKILL.md +10 -76
- package/bin/cache/file-cache.mjs +0 -79
- package/bin/cli.mjs +0 -286
package/bin/cli/args.mjs
CHANGED
|
@@ -1,20 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* CLI argument-parsing helpers extracted from cli.mts for testability.
|
|
3
|
-
* Note: parseCommonArgs calls loadConfig() for cache TTL defaults.
|
|
4
3
|
*/
|
|
5
4
|
import { parseArgs } from "node:util";
|
|
6
|
-
import { loadConfig } from "../config/load.mjs";
|
|
7
|
-
import { deriveVerdict } from "../commands/status.mjs";
|
|
8
5
|
// Flags that consume the next argument as their value (used for PR-number
|
|
9
6
|
// detection only — prevents a flag's value from being mistaken for a PR number).
|
|
10
7
|
const FLAGS_WITH_VALUES = new Set([
|
|
11
8
|
"--format",
|
|
12
|
-
"--cache-ttl",
|
|
13
|
-
"--last-push-time",
|
|
14
9
|
"--ready-delay",
|
|
15
10
|
"--cooldown-seconds",
|
|
11
|
+
"--stall-timeout",
|
|
16
12
|
"--require-sha",
|
|
17
13
|
"--message",
|
|
14
|
+
"--description",
|
|
15
|
+
"--thread-id",
|
|
16
|
+
"--resolve-thread-ids",
|
|
17
|
+
"--minimize-comment-ids",
|
|
18
|
+
"--dismiss-review-ids",
|
|
19
|
+
]);
|
|
20
|
+
// Boolean flags that do NOT consume the next argument. Any --flag not in this
|
|
21
|
+
// set and not in FLAGS_WITH_VALUES is treated conservatively as value-taking
|
|
22
|
+
// for PR-number detection — so removed flags don't silently cause their
|
|
23
|
+
// numeric value to be misidentified as the PR number.
|
|
24
|
+
const BOOLEAN_FLAGS = new Set([
|
|
25
|
+
"--fetch",
|
|
26
|
+
"--no-auto-mark-ready",
|
|
27
|
+
"--no-auto-cancel-actionable",
|
|
28
|
+
"--dry-run",
|
|
18
29
|
]);
|
|
19
30
|
// ---------------------------------------------------------------------------
|
|
20
31
|
// Strict integer parsing
|
|
@@ -26,7 +37,6 @@ export function parseIntStrict(value, flag) {
|
|
|
26
37
|
return parseInt(value, 10);
|
|
27
38
|
}
|
|
28
39
|
export function parseCommonArgs(args) {
|
|
29
|
-
const config = loadConfig();
|
|
30
40
|
const { values, tokens } = parseArgs({
|
|
31
41
|
args,
|
|
32
42
|
strict: false,
|
|
@@ -34,22 +44,14 @@ export function parseCommonArgs(args) {
|
|
|
34
44
|
tokens: true,
|
|
35
45
|
options: {
|
|
36
46
|
format: { type: "string" },
|
|
37
|
-
"cache-ttl": { type: "string" },
|
|
38
|
-
"no-cache": { type: "boolean" },
|
|
39
47
|
},
|
|
40
48
|
});
|
|
41
49
|
const format = (values.format ?? "text");
|
|
42
|
-
const noCache = (values["no-cache"] ?? false);
|
|
43
|
-
const cacheTtlStr = values["cache-ttl"];
|
|
44
|
-
const cacheTtlSeconds = cacheTtlStr
|
|
45
|
-
? parseIntStrict(cacheTtlStr, "--cache-ttl")
|
|
46
|
-
: config.cache.ttlSeconds;
|
|
47
50
|
// Build the set of arg indices consumed by global flags so we can strip
|
|
48
51
|
// them from `extra`. Subcommand-specific flags are left untouched.
|
|
49
52
|
const consumedIndices = new Set();
|
|
50
53
|
for (const tok of tokens ?? []) {
|
|
51
|
-
if (tok.kind === "option" &&
|
|
52
|
-
(tok.name === "format" || tok.name === "cache-ttl" || tok.name === "no-cache")) {
|
|
54
|
+
if (tok.kind === "option" && tok.name === "format") {
|
|
53
55
|
consumedIndices.add(tok.index);
|
|
54
56
|
// When the value is a separate arg (--flag value, not --flag=value),
|
|
55
57
|
// inlineValue is false and the value occupies tok.index + 1.
|
|
@@ -59,7 +61,9 @@ export function parseCommonArgs(args) {
|
|
|
59
61
|
}
|
|
60
62
|
}
|
|
61
63
|
// Find the first positional arg that looks like a PR number, skipping values
|
|
62
|
-
// that belong to flags
|
|
64
|
+
// that belong to value-taking flags. Any --flag not in BOOLEAN_FLAGS is
|
|
65
|
+
// treated conservatively as value-taking so that removed flags don't cause
|
|
66
|
+
// their numeric value to be misidentified as the PR number.
|
|
63
67
|
const skipForPrDetect = new Set();
|
|
64
68
|
for (let i = 0; i < args.length; i += 1) {
|
|
65
69
|
const arg = args[i];
|
|
@@ -69,6 +73,14 @@ export function parseCommonArgs(args) {
|
|
|
69
73
|
skipForPrDetect.add(i + 1);
|
|
70
74
|
i += 1;
|
|
71
75
|
}
|
|
76
|
+
else if (arg.startsWith("--") && !arg.includes("=") && !BOOLEAN_FLAGS.has(arg)) {
|
|
77
|
+
// Unknown non-boolean flag: conservatively skip the next non-flag arg.
|
|
78
|
+
skipForPrDetect.add(i);
|
|
79
|
+
if (i + 1 < args.length && !args[i + 1].startsWith("--")) {
|
|
80
|
+
skipForPrDetect.add(i + 1);
|
|
81
|
+
i += 1;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
72
84
|
else {
|
|
73
85
|
const eqIdx = arg.indexOf("=");
|
|
74
86
|
if (eqIdx > 0 && FLAGS_WITH_VALUES.has(arg.slice(0, eqIdx))) {
|
|
@@ -85,7 +97,7 @@ export function parseCommonArgs(args) {
|
|
|
85
97
|
const extra = args.filter((_, i) => !consumedIndices.has(i));
|
|
86
98
|
return {
|
|
87
99
|
prNumber,
|
|
88
|
-
global: { format
|
|
100
|
+
global: { format },
|
|
89
101
|
extra,
|
|
90
102
|
};
|
|
91
103
|
}
|
|
@@ -127,47 +139,3 @@ export function parseStatusPrNumbers(args) {
|
|
|
127
139
|
}
|
|
128
140
|
return prNumbers;
|
|
129
141
|
}
|
|
130
|
-
// ---------------------------------------------------------------------------
|
|
131
|
-
// Duration parsing
|
|
132
|
-
// ---------------------------------------------------------------------------
|
|
133
|
-
export function parseDurationToMinutes(s, defaultMinutes) {
|
|
134
|
-
const m = /^(\d+)(m|min|minutes?|h|hours?)?$/.exec(s.trim());
|
|
135
|
-
if (!m)
|
|
136
|
-
return defaultMinutes ?? loadConfig().watch.readyDelayMinutes;
|
|
137
|
-
const n = parseInt(m[1], 10);
|
|
138
|
-
const unit = m[2] ?? "m";
|
|
139
|
-
if (unit.startsWith("h"))
|
|
140
|
-
return n * 60;
|
|
141
|
-
return n;
|
|
142
|
-
}
|
|
143
|
-
// ---------------------------------------------------------------------------
|
|
144
|
-
// Exit code mapping
|
|
145
|
-
// ---------------------------------------------------------------------------
|
|
146
|
-
export function statusToExitCode(status) {
|
|
147
|
-
switch (status) {
|
|
148
|
-
case "READY":
|
|
149
|
-
return 0;
|
|
150
|
-
case "IN_PROGRESS":
|
|
151
|
-
return 2;
|
|
152
|
-
case "UNRESOLVED_COMMENTS":
|
|
153
|
-
return 3;
|
|
154
|
-
default:
|
|
155
|
-
return 1;
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
export function iterateActionToExitCode(action) {
|
|
159
|
-
switch (action) {
|
|
160
|
-
case "fix_code":
|
|
161
|
-
case "rebase":
|
|
162
|
-
return 1;
|
|
163
|
-
case "cancel":
|
|
164
|
-
return 2;
|
|
165
|
-
case "escalate":
|
|
166
|
-
return 3;
|
|
167
|
-
default:
|
|
168
|
-
return 0;
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
export function deriveSimpleReady(s) {
|
|
172
|
-
return deriveVerdict(s) === "READY";
|
|
173
|
-
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { loadConfig } from "../config/load.mjs";
|
|
2
|
+
import { deriveVerdict } from "../commands/status.mjs";
|
|
3
|
+
export function parseDurationToMinutes(s, defaultMinutes) {
|
|
4
|
+
const m = /^(\d+)(m|min|minutes?|h|hours?)?$/.exec(s.trim());
|
|
5
|
+
if (!m)
|
|
6
|
+
return defaultMinutes ?? loadConfig().watch.readyDelayMinutes;
|
|
7
|
+
const n = parseInt(m[1], 10);
|
|
8
|
+
const unit = m[2] ?? "m";
|
|
9
|
+
if (unit.startsWith("h"))
|
|
10
|
+
return n * 60;
|
|
11
|
+
return n;
|
|
12
|
+
}
|
|
13
|
+
export function statusToExitCode(status) {
|
|
14
|
+
switch (status) {
|
|
15
|
+
case "READY":
|
|
16
|
+
return 0;
|
|
17
|
+
case "IN_PROGRESS":
|
|
18
|
+
return 2;
|
|
19
|
+
case "UNRESOLVED_COMMENTS":
|
|
20
|
+
return 3;
|
|
21
|
+
default:
|
|
22
|
+
return 1;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function iterateActionToExitCode(action) {
|
|
26
|
+
switch (action) {
|
|
27
|
+
case "fix_code":
|
|
28
|
+
return 1;
|
|
29
|
+
case "cancel":
|
|
30
|
+
return 2;
|
|
31
|
+
case "escalate":
|
|
32
|
+
return 3;
|
|
33
|
+
default:
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function deriveSimpleReady(s) {
|
|
38
|
+
return deriveVerdict(s) === "READY";
|
|
39
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { renderResolveCommand } from "../commands/iterate.mjs";
|
|
2
|
+
export function formatFixCodeResult(header, result) {
|
|
3
|
+
const sections = [header];
|
|
4
|
+
if (result.fix.threads.length > 0) {
|
|
5
|
+
sections.push("## Review threads");
|
|
6
|
+
for (const t of result.fix.threads) {
|
|
7
|
+
const loc = t.path ? `\`${t.path}:${t.line ?? "?"}\`` : "(no location)";
|
|
8
|
+
sections.push(`### \`${t.id}\` — ${loc} (@${t.author})`);
|
|
9
|
+
sections.push(blockquote(t.body));
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
if (result.fix.actionableComments.length > 0) {
|
|
13
|
+
sections.push("## Actionable comments");
|
|
14
|
+
for (const c of result.fix.actionableComments) {
|
|
15
|
+
sections.push(`### \`${c.id}\` (@${c.author})`);
|
|
16
|
+
sections.push(blockquote(c.body));
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
if (result.fix.checks.length > 0) {
|
|
20
|
+
sections.push("## Failing checks");
|
|
21
|
+
const bullets = result.fix.checks.map((ch) => {
|
|
22
|
+
const prefix = ch.workflowName ? `${ch.workflowName} › ` : "";
|
|
23
|
+
const locator = ch.runId
|
|
24
|
+
? `\`${ch.runId}\``
|
|
25
|
+
: ch.detailsUrl
|
|
26
|
+
? `external \`${ch.detailsUrl}\``
|
|
27
|
+
: "(no runId)";
|
|
28
|
+
const lines = [`- ${locator} — \`${prefix}${ch.name}\``];
|
|
29
|
+
if (ch.failedStep)
|
|
30
|
+
lines.push(` > ${ch.failedStep}`);
|
|
31
|
+
if (ch.summary)
|
|
32
|
+
lines.push(` > ${ch.summary}`);
|
|
33
|
+
return lines.join("\n");
|
|
34
|
+
});
|
|
35
|
+
sections.push(bullets.join("\n\n"));
|
|
36
|
+
}
|
|
37
|
+
if (result.fix.changesRequestedReviews.length > 0) {
|
|
38
|
+
sections.push("## Changes-requested reviews");
|
|
39
|
+
sections.push(result.fix.changesRequestedReviews.map((r) => `- \`${r.id}\` (@${r.author})`).join("\n"));
|
|
40
|
+
}
|
|
41
|
+
if (result.fix.noiseCommentIds.length > 0) {
|
|
42
|
+
sections.push("## Noise (minimize only)");
|
|
43
|
+
sections.push(result.fix.noiseCommentIds.map((id) => `\`${id}\``).join(", "));
|
|
44
|
+
}
|
|
45
|
+
if (result.fix.reviewSummaryIds.length > 0) {
|
|
46
|
+
sections.push("## Review summaries (minimize only)");
|
|
47
|
+
sections.push(result.fix.reviewSummaryIds.map((id) => `\`${id}\``).join(", "));
|
|
48
|
+
}
|
|
49
|
+
if (result.fix.surfacedSummaries.length > 0) {
|
|
50
|
+
sections.push("## Review summaries (surfaced — not minimized)");
|
|
51
|
+
for (const r of result.fix.surfacedSummaries) {
|
|
52
|
+
sections.push(`### \`${r.id}\` (@${r.author})`);
|
|
53
|
+
sections.push(blockquote(r.body));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (result.cancelled.length > 0) {
|
|
57
|
+
sections.push("## Cancelled runs");
|
|
58
|
+
sections.push(result.cancelled.map((id) => `\`${id}\``).join(", "));
|
|
59
|
+
}
|
|
60
|
+
sections.push("## Post-fix push");
|
|
61
|
+
const postFixLines = [`- base: \`${result.baseBranch}\``];
|
|
62
|
+
if (result.fix.resolveCommand.hasMutations) {
|
|
63
|
+
postFixLines.push(`- resolve: \`${renderResolveCommand(result.fix.resolveCommand)}\``);
|
|
64
|
+
}
|
|
65
|
+
sections.push(postFixLines.join("\n"));
|
|
66
|
+
sections.push("## Instructions");
|
|
67
|
+
sections.push(result.fix.instructions.map((inst, i) => `${i + 1}. ${inst}`).join("\n"));
|
|
68
|
+
return sections.join("\n\n");
|
|
69
|
+
}
|
|
70
|
+
export function blockquote(body) {
|
|
71
|
+
return body
|
|
72
|
+
.replace(/\r\n/g, "\n")
|
|
73
|
+
.split("\n")
|
|
74
|
+
.map((line) => (line === "" ? ">" : `> ${line}`))
|
|
75
|
+
.join("\n");
|
|
76
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
export { formatIterateResult } from "./iterate-formatter.mjs";
|
|
2
|
+
export function formatFetchResult(result) {
|
|
3
|
+
const total = result.actionableThreads.length +
|
|
4
|
+
result.actionableComments.length +
|
|
5
|
+
result.changesRequestedReviews.length +
|
|
6
|
+
result.reviewSummaries.length;
|
|
7
|
+
const sections = [];
|
|
8
|
+
sections.push(`# PR #${result.prNumber} — Resolve fetch (${total === 0 ? "0 actionable" : `${total} actionable`})`);
|
|
9
|
+
if (result.actionableThreads.length > 0) {
|
|
10
|
+
sections.push(`## Actionable Review Threads (${result.actionableThreads.length})` +
|
|
11
|
+
(result.commitSuggestionsEnabled ? " [commit-suggestions: enabled]" : ""));
|
|
12
|
+
sections.push(result.actionableThreads
|
|
13
|
+
.map((t) => {
|
|
14
|
+
const suggestionMarker = t.suggestion ? " [suggestion]" : "";
|
|
15
|
+
return `- \`threadId=${t.id}\` \`${t.path ?? ""}:${t.line ?? "?"}\` (@${t.author})${suggestionMarker}: ${t.body.split("\n")[0]?.slice(0, 100) ?? ""}`;
|
|
16
|
+
})
|
|
17
|
+
.join("\n"));
|
|
18
|
+
}
|
|
19
|
+
if (result.actionableComments.length > 0) {
|
|
20
|
+
sections.push(`## Actionable PR Comments (${result.actionableComments.length})`);
|
|
21
|
+
sections.push(result.actionableComments
|
|
22
|
+
.map((c) => `- \`commentId=${c.id}\` (@${c.author}): ${c.body.split("\n")[0]?.slice(0, 100) ?? ""}`)
|
|
23
|
+
.join("\n"));
|
|
24
|
+
}
|
|
25
|
+
if (result.changesRequestedReviews.length > 0) {
|
|
26
|
+
sections.push(`## Pending CHANGES_REQUESTED reviews (${result.changesRequestedReviews.length})`);
|
|
27
|
+
sections.push(result.changesRequestedReviews.map((r) => `- \`reviewId=${r.id}\` (@${r.author})`).join("\n"));
|
|
28
|
+
}
|
|
29
|
+
if (result.reviewSummaries.length > 0) {
|
|
30
|
+
sections.push(`## Review summaries (${result.reviewSummaries.length})`);
|
|
31
|
+
sections.push(result.reviewSummaries
|
|
32
|
+
.map((r) => `- \`reviewId=${r.id}\` (@${r.author}): ${r.body.split("\n")[0].slice(0, 100)}`)
|
|
33
|
+
.join("\n"));
|
|
34
|
+
}
|
|
35
|
+
sections.push("## Summary");
|
|
36
|
+
sections.push(total === 0 ? "0 actionable — all threads resolved/minimized" : `${total} actionable item(s)`);
|
|
37
|
+
sections.push("## Instructions");
|
|
38
|
+
sections.push(result.instructions.map((inst, i) => `${i + 1}. ${inst}`).join("\n"));
|
|
39
|
+
return `${sections.join("\n\n")}\n`;
|
|
40
|
+
}
|
|
41
|
+
export function formatCommitSuggestionResult(result) {
|
|
42
|
+
const lines = [];
|
|
43
|
+
if (result.dryRun) {
|
|
44
|
+
const range = result.startLine === result.endLine
|
|
45
|
+
? `line ${result.startLine}`
|
|
46
|
+
: `lines ${result.startLine}-${result.endLine}`;
|
|
47
|
+
if (result.valid) {
|
|
48
|
+
lines.push(`Dry-run: would apply suggestion from @${result.author}:`);
|
|
49
|
+
lines.push(` ${result.path} (${range})`);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
lines.push(`Dry-run: suggestion cannot apply cleanly:`);
|
|
53
|
+
lines.push(`- path: ${result.path} (${range})`);
|
|
54
|
+
lines.push(`- author: @${result.author}`);
|
|
55
|
+
lines.push(`- reason: ${result.reason ?? "unknown"}`);
|
|
56
|
+
}
|
|
57
|
+
if (result.patch) {
|
|
58
|
+
lines.push("");
|
|
59
|
+
lines.push("```diff");
|
|
60
|
+
lines.push(result.patch.trimEnd());
|
|
61
|
+
lines.push("```");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
else if (result.applied) {
|
|
65
|
+
lines.push(`Applied suggestion from @${result.author}:`);
|
|
66
|
+
const range = result.startLine === result.endLine
|
|
67
|
+
? `line ${result.startLine}`
|
|
68
|
+
: `lines ${result.startLine}-${result.endLine}`;
|
|
69
|
+
lines.push(` ${result.path} (${range})`);
|
|
70
|
+
if (result.commitSha)
|
|
71
|
+
lines.push(`Commit: ${result.commitSha}`);
|
|
72
|
+
if (result.patch) {
|
|
73
|
+
lines.push("");
|
|
74
|
+
lines.push("```diff");
|
|
75
|
+
lines.push(result.patch.trimEnd());
|
|
76
|
+
lines.push("```");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
lines.push(`Failed to apply suggestion ${result.threadId}:`);
|
|
81
|
+
lines.push(`- path: ${result.path} (lines ${result.startLine}–${result.endLine})`);
|
|
82
|
+
lines.push(`- author: @${result.author}`);
|
|
83
|
+
lines.push(`- reason: ${result.reason ?? "unknown"}`);
|
|
84
|
+
if (result.patch) {
|
|
85
|
+
lines.push("");
|
|
86
|
+
lines.push("```diff");
|
|
87
|
+
lines.push(result.patch.trimEnd());
|
|
88
|
+
lines.push("```");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (result.postActionInstruction) {
|
|
92
|
+
lines.push("");
|
|
93
|
+
lines.push(result.postActionInstruction);
|
|
94
|
+
}
|
|
95
|
+
return `${lines.join("\n")}\n`;
|
|
96
|
+
}
|
|
97
|
+
export function formatMutateResult(result) {
|
|
98
|
+
const lines = [];
|
|
99
|
+
if (result.resolvedThreads.length)
|
|
100
|
+
lines.push(`Resolved threads (${result.resolvedThreads.length}): ${result.resolvedThreads.join(", ")}`);
|
|
101
|
+
if (result.minimizedComments.length)
|
|
102
|
+
lines.push(`Minimized comments (${result.minimizedComments.length}): ${result.minimizedComments.join(", ")}`);
|
|
103
|
+
if (result.dismissedReviews.length)
|
|
104
|
+
lines.push(`Dismissed reviews (${result.dismissedReviews.length}): ${result.dismissedReviews.join(", ")}`);
|
|
105
|
+
if (result.errors.length)
|
|
106
|
+
lines.push(`Errors:\n ${result.errors.join("\n ")}`);
|
|
107
|
+
return `${lines.join("\n")}\n`;
|
|
108
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { runCommitSuggestion } from "../commands/commit-suggestion.mjs";
|
|
2
|
+
import { runIterate } from "../commands/iterate.mjs";
|
|
3
|
+
import { runMonitor, formatMonitorResult } from "../commands/monitor.mjs";
|
|
4
|
+
import { runStatus, formatStatusTable } from "../commands/status.mjs";
|
|
5
|
+
import { getRepoInfo } from "../github/client.mjs";
|
|
6
|
+
import { loadConfig } from "../config/load.mjs";
|
|
7
|
+
import { parseCommonArgs, getFlag, hasFlag, parseStatusPrNumbers, parseIntStrict, } from "./args.mjs";
|
|
8
|
+
import { parseDurationToMinutes, iterateActionToExitCode, deriveSimpleReady, } from "./exit-codes.mjs";
|
|
9
|
+
import { formatCommitSuggestionResult, formatIterateResult } from "./formatters.mjs";
|
|
10
|
+
export async function handleCommitSuggestion(args) {
|
|
11
|
+
const { prNumber, global: globalOpts, extra } = parseCommonArgs(args);
|
|
12
|
+
const threadId = getFlag(extra, "--thread-id");
|
|
13
|
+
if (!threadId) {
|
|
14
|
+
process.stderr.write("Usage: pr-shepherd commit-suggestion [PR] --thread-id ID [--message MSG] [--description DESC] [--dry-run]\n" +
|
|
15
|
+
" (--message is required unless --dry-run is set)\n");
|
|
16
|
+
process.exitCode = 1;
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const dryRun = hasFlag(extra, "--dry-run");
|
|
20
|
+
const message = getFlag(extra, "--message") ?? undefined;
|
|
21
|
+
if (!dryRun && (!message || message.trim() === "")) {
|
|
22
|
+
process.stderr.write("--message is required and must be non-empty\n");
|
|
23
|
+
process.exitCode = 1;
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const description = getFlag(extra, "--description") ?? undefined;
|
|
27
|
+
const result = await runCommitSuggestion({
|
|
28
|
+
...globalOpts,
|
|
29
|
+
prNumber,
|
|
30
|
+
threadId,
|
|
31
|
+
message,
|
|
32
|
+
description,
|
|
33
|
+
dryRun,
|
|
34
|
+
});
|
|
35
|
+
process.stdout.write(globalOpts.format === "json"
|
|
36
|
+
? `${JSON.stringify(result, null, 2)}\n`
|
|
37
|
+
: formatCommitSuggestionResult(result));
|
|
38
|
+
if (result.dryRun) {
|
|
39
|
+
process.exitCode = result.valid ? 0 : 1;
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
process.exitCode = result.applied ? 0 : 1;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export async function handleIterate(args) {
|
|
46
|
+
const { prNumber, global: globalOpts, extra } = parseCommonArgs(args);
|
|
47
|
+
const readyDelayStr = getFlag(extra, "--ready-delay");
|
|
48
|
+
const cfg = loadConfig();
|
|
49
|
+
const readyDelaySeconds = parseDurationToMinutes(readyDelayStr ?? "", cfg.watch.readyDelayMinutes) * 60;
|
|
50
|
+
const cooldownSecondsStr = getFlag(extra, "--cooldown-seconds");
|
|
51
|
+
const cooldownSeconds = cooldownSecondsStr
|
|
52
|
+
? parseIntStrict(cooldownSecondsStr, "--cooldown-seconds")
|
|
53
|
+
: cfg.iterate.cooldownSeconds;
|
|
54
|
+
const noAutoMarkReady = hasFlag(extra, "--no-auto-mark-ready");
|
|
55
|
+
const noAutoCancelActionable = hasFlag(extra, "--no-auto-cancel-actionable");
|
|
56
|
+
const stallTimeoutStr = getFlag(extra, "--stall-timeout");
|
|
57
|
+
const stallTimeoutSeconds = stallTimeoutStr
|
|
58
|
+
? parseDurationToMinutes(stallTimeoutStr, cfg.iterate.stallTimeoutMinutes) * 60
|
|
59
|
+
: cfg.iterate.stallTimeoutMinutes * 60;
|
|
60
|
+
const result = await runIterate({
|
|
61
|
+
...globalOpts,
|
|
62
|
+
prNumber,
|
|
63
|
+
readyDelaySeconds,
|
|
64
|
+
cooldownSeconds,
|
|
65
|
+
stallTimeoutSeconds,
|
|
66
|
+
noAutoMarkReady,
|
|
67
|
+
noAutoCancelActionable,
|
|
68
|
+
});
|
|
69
|
+
if (globalOpts.format === "json") {
|
|
70
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
process.stdout.write(`${formatIterateResult(result)}\n`);
|
|
74
|
+
}
|
|
75
|
+
process.exitCode = iterateActionToExitCode(result.action);
|
|
76
|
+
}
|
|
77
|
+
export async function handleMonitor(args) {
|
|
78
|
+
const { prNumber, global: globalOpts, extra } = parseCommonArgs(args);
|
|
79
|
+
const readyDelayStr = getFlag(extra, "--ready-delay");
|
|
80
|
+
if (hasFlag(extra, "--ready-delay") &&
|
|
81
|
+
(readyDelayStr === null || readyDelayStr.startsWith("--"))) {
|
|
82
|
+
process.stderr.write("pr-shepherd monitor: --ready-delay requires a value (e.g. --ready-delay 15m)\n");
|
|
83
|
+
process.exitCode = 1;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const remaining = [];
|
|
87
|
+
for (let i = 0; i < extra.length; i++) {
|
|
88
|
+
const a = extra[i];
|
|
89
|
+
if (a === "--ready-delay") {
|
|
90
|
+
i++;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (a.startsWith("--ready-delay="))
|
|
94
|
+
continue;
|
|
95
|
+
remaining.push(a);
|
|
96
|
+
}
|
|
97
|
+
const unknownFlags = remaining.filter((a) => a.startsWith("--"));
|
|
98
|
+
if (unknownFlags.length > 0) {
|
|
99
|
+
process.stderr.write(`pr-shepherd monitor: ignoring unknown flags: ${unknownFlags.join(" ")}\n`);
|
|
100
|
+
}
|
|
101
|
+
const unknownPositionals = remaining.filter((a) => !a.startsWith("--"));
|
|
102
|
+
if (unknownPositionals.length > 0) {
|
|
103
|
+
process.stderr.write(`pr-shepherd monitor: unexpected positional arguments ignored: ${unknownPositionals.join(" ")}\n`);
|
|
104
|
+
}
|
|
105
|
+
let result;
|
|
106
|
+
try {
|
|
107
|
+
result = await runMonitor({
|
|
108
|
+
...globalOpts,
|
|
109
|
+
prNumber,
|
|
110
|
+
readyDelaySuffix: readyDelayStr ?? undefined,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
115
|
+
process.exitCode = 1;
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
process.stdout.write(globalOpts.format === "json"
|
|
119
|
+
? `${JSON.stringify(result, null, 2)}\n`
|
|
120
|
+
: `${formatMonitorResult(result)}\n`);
|
|
121
|
+
}
|
|
122
|
+
export async function handleStatus(args) {
|
|
123
|
+
const { global: globalOpts } = parseCommonArgs(args);
|
|
124
|
+
const prNumbers = parseStatusPrNumbers(args);
|
|
125
|
+
if (prNumbers.length === 0) {
|
|
126
|
+
process.stderr.write("Usage: pr-shepherd status PR1 [PR2 …]\n");
|
|
127
|
+
process.exitCode = 1;
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const repo = await getRepoInfo();
|
|
131
|
+
const summaries = await runStatus({ ...globalOpts, prNumbers });
|
|
132
|
+
const output = globalOpts.format === "json"
|
|
133
|
+
? JSON.stringify(summaries, null, 2)
|
|
134
|
+
: formatStatusTable(summaries, `${repo.owner}/${repo.name}`);
|
|
135
|
+
process.stdout.write(`${output}\n`);
|
|
136
|
+
const allReady = summaries.every((s) => deriveSimpleReady(s));
|
|
137
|
+
process.exitCode = allReady ? 0 : 1;
|
|
138
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { formatFixCodeResult } from "./fix-formatter.mjs";
|
|
2
|
+
/**
|
|
3
|
+
* Format an IterateResult as human-readable Markdown.
|
|
4
|
+
*
|
|
5
|
+
* Load-bearing conventions the monitor SKILL relies on:
|
|
6
|
+
* 1. The H1 heading on line 1 contains `[<ACTION>]` — the action tag identifies
|
|
7
|
+
* the output for logging and validation. Behavior is driven by `## Instructions`,
|
|
8
|
+
* not by dispatching on the tag.
|
|
9
|
+
* 2. `[FIX_CODE]` uses the `rebase-and-push` variant: the `resolve` bullet under
|
|
10
|
+
* `## Post-fix push` wraps the resolve command in backticks — the SKILL
|
|
11
|
+
* extracts the backticked content for execution.
|
|
12
|
+
* 3. Every action ends with a `## Instructions` section — numbered `1.`, `2.`, … —
|
|
13
|
+
* that tells the monitor exactly what to do with this output. The section is
|
|
14
|
+
* unconditional: every action, every variant, always emits at least one step.
|
|
15
|
+
* The SKILL simply follows those steps; it does not need its own dispatch table.
|
|
16
|
+
*/
|
|
17
|
+
export function formatIterateResult(result) {
|
|
18
|
+
const heading = `# PR #${result.pr} [${result.action.toUpperCase()}]`;
|
|
19
|
+
const baseLine = `**status** \`${result.status}\` · **merge** \`${result.mergeStateStatus}\` · **state** \`${result.state}\` · **repo** \`${result.repo}\``;
|
|
20
|
+
const summaryLine = `**summary** ${result.summary.passing} passing, ${result.summary.skipped} skipped, ${result.summary.filtered} filtered, ${result.summary.inProgress} inProgress · **remainingSeconds** ${result.remainingSeconds} · **copilotReviewInProgress** ${result.copilotReviewInProgress} · **isDraft** ${result.isDraft} · **shouldCancel** ${result.shouldCancel}`;
|
|
21
|
+
const header = [heading, "", baseLine, summaryLine].join("\n");
|
|
22
|
+
switch (result.action) {
|
|
23
|
+
case "cooldown":
|
|
24
|
+
return [
|
|
25
|
+
header,
|
|
26
|
+
"",
|
|
27
|
+
result.log,
|
|
28
|
+
"",
|
|
29
|
+
"## Instructions",
|
|
30
|
+
"",
|
|
31
|
+
"1. End this iteration — the next cron fire will recheck once CI starts reporting.",
|
|
32
|
+
].join("\n");
|
|
33
|
+
case "wait": {
|
|
34
|
+
const parts = [
|
|
35
|
+
header,
|
|
36
|
+
result.log,
|
|
37
|
+
"## Instructions",
|
|
38
|
+
"1. End this iteration — the next cron fire will recheck.",
|
|
39
|
+
];
|
|
40
|
+
return parts.join("\n\n").replace(/\n\n\n+/g, "\n\n");
|
|
41
|
+
}
|
|
42
|
+
case "rerun_ci": {
|
|
43
|
+
const rerunInstructions = result.reran.map((r, i) => `${i + 1}. Run: \`gh run rerun ${r.runId} --failed\``);
|
|
44
|
+
rerunInstructions.push(`${rerunInstructions.length + 1}. End this iteration — wait for CI to report results after the re-run.`);
|
|
45
|
+
const parts = [header, result.log, "## Instructions", rerunInstructions.join("\n")];
|
|
46
|
+
return parts.join("\n\n").replace(/\n\n\n+/g, "\n\n");
|
|
47
|
+
}
|
|
48
|
+
case "mark_ready": {
|
|
49
|
+
const parts = [
|
|
50
|
+
header,
|
|
51
|
+
result.log,
|
|
52
|
+
"## Instructions",
|
|
53
|
+
"1. The CLI already marked the PR ready for review — end this iteration.",
|
|
54
|
+
];
|
|
55
|
+
return parts.join("\n\n").replace(/\n\n\n+/g, "\n\n");
|
|
56
|
+
}
|
|
57
|
+
case "cancel": {
|
|
58
|
+
const parts = [
|
|
59
|
+
header,
|
|
60
|
+
result.log,
|
|
61
|
+
"## Instructions",
|
|
62
|
+
"1. Invoke `/loop cancel` via the Skill tool.\n2. Stop.",
|
|
63
|
+
];
|
|
64
|
+
return parts.join("\n\n").replace(/\n\n\n+/g, "\n\n");
|
|
65
|
+
}
|
|
66
|
+
case "escalate": {
|
|
67
|
+
const parts = [
|
|
68
|
+
header,
|
|
69
|
+
result.escalate.humanMessage,
|
|
70
|
+
"## Instructions",
|
|
71
|
+
"1. Invoke `/loop cancel` via the Skill tool.\n2. Stop — the PR needs human direction before monitoring can resume.",
|
|
72
|
+
];
|
|
73
|
+
return parts.join("\n\n").replace(/\n\n\n+/g, "\n\n");
|
|
74
|
+
}
|
|
75
|
+
case "fix_code":
|
|
76
|
+
return formatFixCodeResult(header, result);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export function makeIterateResult(action = "wait") {
|
|
2
|
+
const base = {
|
|
3
|
+
pr: 42,
|
|
4
|
+
repo: "owner/repo",
|
|
5
|
+
status: "IN_PROGRESS",
|
|
6
|
+
state: "OPEN",
|
|
7
|
+
mergeStateStatus: "BLOCKED",
|
|
8
|
+
copilotReviewInProgress: false,
|
|
9
|
+
isDraft: false,
|
|
10
|
+
shouldCancel: false,
|
|
11
|
+
remainingSeconds: 60,
|
|
12
|
+
summary: { passing: 0, skipped: 0, filtered: 0, inProgress: 1 },
|
|
13
|
+
baseBranch: "main",
|
|
14
|
+
checks: [],
|
|
15
|
+
};
|
|
16
|
+
if (action === "cooldown")
|
|
17
|
+
return { ...base, action: "cooldown", log: "SKIP: CI still starting" };
|
|
18
|
+
if (action === "wait")
|
|
19
|
+
return { ...base, action: "wait", log: "WAIT: 0 passing, 1 in-progress" };
|
|
20
|
+
if (action === "rerun_ci")
|
|
21
|
+
return { ...base, action: "rerun_ci", log: "RERAN: run-99 (typecheck — transient)", reran: [] };
|
|
22
|
+
if (action === "mark_ready")
|
|
23
|
+
return { ...base, action: "mark_ready", markedReady: true, log: "MARKED READY: PR 42" };
|
|
24
|
+
if (action === "fix_code") {
|
|
25
|
+
return {
|
|
26
|
+
...base,
|
|
27
|
+
action: "fix_code",
|
|
28
|
+
fix: {
|
|
29
|
+
mode: "rebase-and-push",
|
|
30
|
+
threads: [],
|
|
31
|
+
actionableComments: [],
|
|
32
|
+
noiseCommentIds: [],
|
|
33
|
+
reviewSummaryIds: [],
|
|
34
|
+
surfacedSummaries: [],
|
|
35
|
+
checks: [],
|
|
36
|
+
changesRequestedReviews: [],
|
|
37
|
+
resolveCommand: {
|
|
38
|
+
argv: ["npx", "pr-shepherd", "resolve", "42"],
|
|
39
|
+
requiresHeadSha: true,
|
|
40
|
+
requiresDismissMessage: false,
|
|
41
|
+
hasMutations: false,
|
|
42
|
+
},
|
|
43
|
+
instructions: ["End this iteration."],
|
|
44
|
+
},
|
|
45
|
+
cancelled: [],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
if (action === "cancel")
|
|
49
|
+
return { ...base, action: "cancel", log: "CANCEL: PR #42 — stopping monitor" };
|
|
50
|
+
if (action === "escalate") {
|
|
51
|
+
return {
|
|
52
|
+
...base,
|
|
53
|
+
action: "escalate",
|
|
54
|
+
escalate: {
|
|
55
|
+
triggers: [],
|
|
56
|
+
unresolvedThreads: [],
|
|
57
|
+
ambiguousComments: [],
|
|
58
|
+
changesRequestedReviews: [],
|
|
59
|
+
suggestion: "check manually",
|
|
60
|
+
humanMessage: "⚠️ /pr-shepherd:monitor paused — needs human direction",
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return { ...base, action: "wait", log: "WAIT: 0 passing, 1 in-progress" };
|
|
65
|
+
}
|