pr-shepherd 0.8.1 → 0.10.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 +123 -21
- package/bin/checks/classify.mjs +2 -1
- package/bin/checks/triage.mjs +63 -61
- package/bin/cli/args.mjs +9 -3
- package/bin/cli/fence.mjs +4 -0
- package/bin/cli/fix-formatter.mjs +45 -19
- package/bin/cli/formatters.mjs +61 -44
- package/bin/cli/handlers.mjs +5 -4
- package/bin/cli/iterate-formatter.mjs +51 -45
- package/bin/cli/iterate-lean.mjs +95 -0
- package/bin/cli/list-formatters.mjs +32 -0
- package/bin/cli/suggestion-renderer.mjs +31 -0
- package/bin/cli-parser.iterate-fixtures.mjs +6 -5
- package/bin/cli-parser.mjs +27 -3
- package/bin/commands/check-status.mjs +7 -4
- package/bin/commands/check.mjs +39 -3
- package/bin/commands/commit-suggestion.mjs +12 -10
- package/bin/commands/iterate/classify.mjs +10 -54
- package/bin/commands/iterate/escalate.mjs +5 -14
- package/bin/commands/iterate/fix-code.mjs +15 -16
- package/bin/commands/iterate/helpers.mjs +8 -5
- package/bin/commands/iterate/index.mjs +18 -12
- package/bin/commands/iterate/render.mjs +28 -12
- package/bin/commands/iterate/stall.mjs +1 -1
- package/bin/commands/log-file.mjs +7 -0
- package/bin/commands/monitor.mjs +7 -4
- package/bin/commands/ready-delay.mjs +2 -2
- package/bin/commands/resolve-instructions.mjs +14 -5
- package/bin/commands/resolve.mjs +38 -37
- package/bin/commands/status.mjs +41 -29
- package/bin/comments/resolve.mjs +75 -65
- package/bin/config.json +4 -7
- package/bin/github/batch-parsers.mjs +4 -0
- package/bin/github/client.mjs +11 -32
- package/bin/github/gql/batch-pr.gql +6 -0
- package/bin/github/gql/get-pr-head-sha.gql +7 -0
- package/bin/github/http.mjs +166 -11
- package/bin/github/queries.mjs +7 -11
- package/bin/log/log-file.mjs +88 -0
- package/bin/log/session.mjs +100 -0
- package/bin/log/setup.mjs +54 -0
- package/bin/reporters/agent.mjs +21 -13
- package/bin/reporters/check-instructions.mjs +9 -14
- package/bin/reporters/text.mjs +93 -105
- package/bin/state/base.mjs +5 -0
- package/bin/state/fix-attempts.mjs +2 -2
- package/bin/state/iterate-stall.mjs +2 -2
- package/bin/state/seen-comments.mjs +76 -0
- package/bin/suggestions/extract.mjs +15 -0
- package/bin/suggestions/parse.mjs +1 -26
- package/bin/types/report.mjs +0 -1
- package/bin/util/markdown.mjs +7 -0
- package/bin/util/worktree.mjs +23 -0
- package/package.json +2 -2
- package/bin/commands/iterate/steps.mjs +0 -31
- package/bin/github/gql/dismiss-review.gql +0 -7
- package/bin/github/gql/minimize-comment.gql +0 -7
- package/bin/github/gql/multi-pr-status.gql +0 -32
- package/bin/github/gql/resolve-thread.gql +0 -7
package/bin/cli/formatters.mjs
CHANGED
|
@@ -1,49 +1,79 @@
|
|
|
1
1
|
export { formatIterateResult } from "./iterate-formatter.mjs";
|
|
2
|
+
export { projectIterateLean } from "./iterate-lean.mjs";
|
|
3
|
+
import { safeFence } from "./fence.mjs";
|
|
4
|
+
import { renderThreadBullet, renderCommentBullet, renderReviewBullet, renderFirstLookStatusTag, } from "./list-formatters.mjs";
|
|
5
|
+
import { joinSections } from "../util/markdown.mjs";
|
|
2
6
|
export function formatFetchResult(result) {
|
|
3
|
-
const
|
|
7
|
+
const activeTotal = result.actionableThreads.length +
|
|
4
8
|
result.actionableComments.length +
|
|
5
9
|
result.changesRequestedReviews.length +
|
|
6
10
|
result.reviewSummaries.length;
|
|
11
|
+
const firstLookTotal = result.firstLookThreads.length + result.firstLookComments.length;
|
|
12
|
+
const total = activeTotal + firstLookTotal;
|
|
13
|
+
const headingParts = [];
|
|
14
|
+
if (activeTotal > 0)
|
|
15
|
+
headingParts.push(`${activeTotal} actionable`);
|
|
16
|
+
if (firstLookTotal > 0)
|
|
17
|
+
headingParts.push(`${firstLookTotal} first-look`);
|
|
18
|
+
const headingSuffix = headingParts.length > 0 ? headingParts.join(", ") : "0 actionable";
|
|
7
19
|
const sections = [];
|
|
8
|
-
sections.push(`# PR #${result.prNumber} — Resolve fetch (${
|
|
20
|
+
sections.push(`# PR #${result.prNumber} — Resolve fetch (${headingSuffix})`);
|
|
9
21
|
if (result.actionableThreads.length > 0) {
|
|
10
22
|
sections.push(`## Actionable Review Threads (${result.actionableThreads.length})` +
|
|
11
23
|
(result.commitSuggestionsEnabled ? " [commit-suggestions: enabled]" : ""));
|
|
12
24
|
sections.push(result.actionableThreads
|
|
13
|
-
.map((t) => {
|
|
14
|
-
|
|
15
|
-
return `- \`threadId=${t.id}\` \`${t.path ?? ""}:${t.line ?? "?"}\` (@${t.author})${suggestionMarker}: ${t.body.split("\n")[0]?.slice(0, 100) ?? ""}`;
|
|
16
|
-
})
|
|
17
|
-
.join("\n"));
|
|
25
|
+
.map((t) => renderThreadBullet(t, { renderSuggestion: true }))
|
|
26
|
+
.join("\n\n"));
|
|
18
27
|
}
|
|
19
28
|
if (result.actionableComments.length > 0) {
|
|
20
29
|
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"));
|
|
30
|
+
sections.push(result.actionableComments.map((c) => renderCommentBullet(c)).join("\n"));
|
|
24
31
|
}
|
|
25
32
|
if (result.changesRequestedReviews.length > 0) {
|
|
26
33
|
sections.push(`## Pending CHANGES_REQUESTED reviews (${result.changesRequestedReviews.length})`);
|
|
27
|
-
sections.push(result.changesRequestedReviews.map((r) =>
|
|
34
|
+
sections.push(result.changesRequestedReviews.map((r) => renderReviewBullet(r)).join("\n"));
|
|
28
35
|
}
|
|
29
36
|
if (result.reviewSummaries.length > 0) {
|
|
30
37
|
sections.push(`## Review summaries (${result.reviewSummaries.length})`);
|
|
31
|
-
sections.push(result.reviewSummaries
|
|
32
|
-
|
|
33
|
-
|
|
38
|
+
sections.push(result.reviewSummaries.map((r) => renderReviewBullet(r, { includeBody: true })).join("\n"));
|
|
39
|
+
}
|
|
40
|
+
if (firstLookTotal > 0) {
|
|
41
|
+
sections.push(`## First-look items (${firstLookTotal}) — already closed on GitHub; acknowledge only`);
|
|
42
|
+
const bullets = [];
|
|
43
|
+
for (const t of result.firstLookThreads) {
|
|
44
|
+
bullets.push(renderThreadBullet(t, { statusTag: renderFirstLookStatusTag(t) }));
|
|
45
|
+
}
|
|
46
|
+
for (const c of result.firstLookComments) {
|
|
47
|
+
bullets.push(renderCommentBullet(c, { statusTag: "[status: minimized]" }));
|
|
48
|
+
}
|
|
49
|
+
sections.push(bullets.join("\n"));
|
|
34
50
|
}
|
|
35
51
|
sections.push("## Summary");
|
|
36
|
-
sections.push(total === 0
|
|
52
|
+
sections.push(total === 0
|
|
53
|
+
? "0 actionable, 0 first-look — all items seen"
|
|
54
|
+
: [
|
|
55
|
+
activeTotal > 0 ? `${activeTotal} actionable` : null,
|
|
56
|
+
firstLookTotal > 0 ? `${firstLookTotal} first-look` : null,
|
|
57
|
+
]
|
|
58
|
+
.filter(Boolean)
|
|
59
|
+
.join(", "));
|
|
37
60
|
sections.push("## Instructions");
|
|
38
61
|
sections.push(result.instructions.map((inst, i) => `${i + 1}. ${inst}`).join("\n"));
|
|
39
|
-
return
|
|
62
|
+
return joinSections(sections);
|
|
40
63
|
}
|
|
41
64
|
export function formatCommitSuggestionResult(result) {
|
|
42
65
|
const lines = [];
|
|
66
|
+
const range = result.startLine === result.endLine
|
|
67
|
+
? `line ${result.startLine}`
|
|
68
|
+
: `lines ${result.startLine}–${result.endLine}`;
|
|
69
|
+
function pushPatch(patch) {
|
|
70
|
+
const fence = safeFence(patch);
|
|
71
|
+
lines.push("");
|
|
72
|
+
lines.push(`${fence}diff`);
|
|
73
|
+
lines.push(patch.trimEnd());
|
|
74
|
+
lines.push(fence);
|
|
75
|
+
}
|
|
43
76
|
if (result.dryRun) {
|
|
44
|
-
const range = result.startLine === result.endLine
|
|
45
|
-
? `line ${result.startLine}`
|
|
46
|
-
: `lines ${result.startLine}-${result.endLine}`;
|
|
47
77
|
if (result.valid) {
|
|
48
78
|
lines.push(`Dry-run: would apply suggestion from @${result.author}:`);
|
|
49
79
|
lines.push(` ${result.path} (${range})`);
|
|
@@ -54,45 +84,32 @@ export function formatCommitSuggestionResult(result) {
|
|
|
54
84
|
lines.push(`- author: @${result.author}`);
|
|
55
85
|
lines.push(`- reason: ${result.reason ?? "unknown"}`);
|
|
56
86
|
}
|
|
57
|
-
if (result.patch)
|
|
58
|
-
|
|
59
|
-
lines.push("```diff");
|
|
60
|
-
lines.push(result.patch.trimEnd());
|
|
61
|
-
lines.push("```");
|
|
62
|
-
}
|
|
87
|
+
if (result.patch)
|
|
88
|
+
pushPatch(result.patch);
|
|
63
89
|
}
|
|
64
90
|
else if (result.applied) {
|
|
65
91
|
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
92
|
lines.push(` ${result.path} (${range})`);
|
|
70
93
|
if (result.commitSha)
|
|
71
94
|
lines.push(`Commit: ${result.commitSha}`);
|
|
72
|
-
if (result.patch)
|
|
73
|
-
|
|
74
|
-
lines.push("```diff");
|
|
75
|
-
lines.push(result.patch.trimEnd());
|
|
76
|
-
lines.push("```");
|
|
77
|
-
}
|
|
95
|
+
if (result.patch)
|
|
96
|
+
pushPatch(result.patch);
|
|
78
97
|
}
|
|
79
98
|
else {
|
|
80
99
|
lines.push(`Failed to apply suggestion ${result.threadId}:`);
|
|
81
|
-
lines.push(`- path: ${result.path} (
|
|
100
|
+
lines.push(`- path: ${result.path} (${range})`);
|
|
82
101
|
lines.push(`- author: @${result.author}`);
|
|
83
102
|
lines.push(`- reason: ${result.reason ?? "unknown"}`);
|
|
84
|
-
if (result.patch)
|
|
85
|
-
|
|
86
|
-
lines.push("```diff");
|
|
87
|
-
lines.push(result.patch.trimEnd());
|
|
88
|
-
lines.push("```");
|
|
89
|
-
}
|
|
103
|
+
if (result.patch)
|
|
104
|
+
pushPatch(result.patch);
|
|
90
105
|
}
|
|
91
106
|
if (result.postActionInstruction) {
|
|
92
107
|
lines.push("");
|
|
93
|
-
lines.push(
|
|
108
|
+
lines.push("## Instructions");
|
|
109
|
+
lines.push("");
|
|
110
|
+
lines.push(`1. ${result.postActionInstruction}`);
|
|
94
111
|
}
|
|
95
|
-
return
|
|
112
|
+
return lines.join("\n");
|
|
96
113
|
}
|
|
97
114
|
export function formatMutateResult(result) {
|
|
98
115
|
const lines = [];
|
|
@@ -104,5 +121,5 @@ export function formatMutateResult(result) {
|
|
|
104
121
|
lines.push(`Dismissed reviews (${result.dismissedReviews.length}): ${result.dismissedReviews.join(", ")}`);
|
|
105
122
|
if (result.errors.length)
|
|
106
123
|
lines.push(`Errors:\n ${result.errors.join("\n ")}`);
|
|
107
|
-
return
|
|
124
|
+
return lines.join("\n");
|
|
108
125
|
}
|
package/bin/cli/handlers.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { getRepoInfo } from "../github/client.mjs";
|
|
|
6
6
|
import { loadConfig } from "../config/load.mjs";
|
|
7
7
|
import { parseCommonArgs, getFlag, hasFlag, parseStatusPrNumbers, parseIntStrict, } from "./args.mjs";
|
|
8
8
|
import { parseDurationToMinutes, iterateActionToExitCode, deriveSimpleReady, } from "./exit-codes.mjs";
|
|
9
|
-
import { formatCommitSuggestionResult, formatIterateResult } from "./formatters.mjs";
|
|
9
|
+
import { formatCommitSuggestionResult, formatIterateResult, projectIterateLean, } from "./formatters.mjs";
|
|
10
10
|
export async function handleCommitSuggestion(args) {
|
|
11
11
|
const { prNumber, global: globalOpts, extra } = parseCommonArgs(args);
|
|
12
12
|
const threadId = getFlag(extra, "--thread-id");
|
|
@@ -34,7 +34,7 @@ export async function handleCommitSuggestion(args) {
|
|
|
34
34
|
});
|
|
35
35
|
process.stdout.write(globalOpts.format === "json"
|
|
36
36
|
? `${JSON.stringify(result, null, 2)}\n`
|
|
37
|
-
: formatCommitSuggestionResult(result));
|
|
37
|
+
: `${formatCommitSuggestionResult(result)}\n`);
|
|
38
38
|
if (result.dryRun) {
|
|
39
39
|
process.exitCode = result.valid ? 0 : 1;
|
|
40
40
|
}
|
|
@@ -67,10 +67,11 @@ export async function handleIterate(args) {
|
|
|
67
67
|
noAutoCancelActionable,
|
|
68
68
|
});
|
|
69
69
|
if (globalOpts.format === "json") {
|
|
70
|
-
|
|
70
|
+
const output = globalOpts.verbose ? result : projectIterateLean(result);
|
|
71
|
+
process.stdout.write(`${JSON.stringify(output)}\n`);
|
|
71
72
|
}
|
|
72
73
|
else {
|
|
73
|
-
process.stdout.write(`${formatIterateResult(result)}\n`);
|
|
74
|
+
process.stdout.write(`${formatIterateResult(result, { verbose: globalOpts.verbose })}\n`);
|
|
74
75
|
}
|
|
75
76
|
process.exitCode = iterateActionToExitCode(result.action);
|
|
76
77
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { formatFixCodeResult } from "./fix-formatter.mjs";
|
|
2
|
+
import { joinSections } from "../util/markdown.mjs";
|
|
2
3
|
/**
|
|
3
4
|
* Format an IterateResult as human-readable Markdown.
|
|
4
5
|
*
|
|
@@ -14,64 +15,69 @@ import { formatFixCodeResult } from "./fix-formatter.mjs";
|
|
|
14
15
|
* unconditional: every action, every variant, always emits at least one step.
|
|
15
16
|
* The SKILL simply follows those steps; it does not need its own dispatch table.
|
|
16
17
|
*/
|
|
17
|
-
export function formatIterateResult(result) {
|
|
18
|
+
export function formatIterateResult(result, opts) {
|
|
19
|
+
const verbose = opts?.verbose ?? false;
|
|
18
20
|
const heading = `# PR #${result.pr} [${result.action.toUpperCase()}]`;
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
+
const reviewDecisionSeg = result.mergeStatus === "BLOCKED" && result.reviewDecision
|
|
22
|
+
? ` · **reviewDecision** \`${result.reviewDecision}\``
|
|
23
|
+
: "";
|
|
24
|
+
const baseLine = `**status** \`${result.status}\` · **merge** \`${result.mergeStateStatus}\`${reviewDecisionSeg} · **state** \`${result.state}\` · **repo** \`${result.repo}\``;
|
|
25
|
+
let summaryLine;
|
|
26
|
+
if (verbose) {
|
|
27
|
+
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}`;
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
const counts = [`${result.summary.passing} passing`];
|
|
31
|
+
if (result.summary.skipped > 0)
|
|
32
|
+
counts.push(`${result.summary.skipped} skipped`);
|
|
33
|
+
if (result.summary.filtered > 0)
|
|
34
|
+
counts.push(`${result.summary.filtered} filtered`);
|
|
35
|
+
if (result.summary.inProgress > 0)
|
|
36
|
+
counts.push(`${result.summary.inProgress} inProgress`);
|
|
37
|
+
const segs = [`**summary** ${counts.join(", ")}`];
|
|
38
|
+
if (result.status === "READY" && result.remainingSeconds > 0) {
|
|
39
|
+
segs.push(`**remainingSeconds** ${result.remainingSeconds}`);
|
|
40
|
+
}
|
|
41
|
+
if (result.copilotReviewInProgress)
|
|
42
|
+
segs.push(`**copilotReviewInProgress**`);
|
|
43
|
+
if (result.isDraft)
|
|
44
|
+
segs.push(`**isDraft**`);
|
|
45
|
+
summaryLine = segs.join(" · ");
|
|
46
|
+
}
|
|
21
47
|
const header = [heading, "", baseLine, summaryLine].join("\n");
|
|
22
48
|
switch (result.action) {
|
|
23
49
|
case "cooldown":
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
50
|
+
// In default mode: suppress base/summary lines — cooldown carries UNKNOWN/empty
|
|
51
|
+
// placeholders that add no value. Emit only heading + log + Instructions.
|
|
52
|
+
return joinSections([
|
|
53
|
+
verbose ? header : heading,
|
|
27
54
|
result.log,
|
|
28
|
-
"",
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
].join("\n");
|
|
33
|
-
case "wait": {
|
|
34
|
-
const parts = [
|
|
55
|
+
"## Instructions\n\n1. End this iteration — the next cron fire will recheck once CI starts reporting.",
|
|
56
|
+
]);
|
|
57
|
+
case "wait":
|
|
58
|
+
return joinSections([
|
|
35
59
|
header,
|
|
36
60
|
result.log,
|
|
37
|
-
"## Instructions",
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
return
|
|
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 = [
|
|
61
|
+
"## Instructions\n\n1. End this iteration — the next cron fire will recheck.",
|
|
62
|
+
]);
|
|
63
|
+
case "mark_ready":
|
|
64
|
+
return joinSections([
|
|
50
65
|
header,
|
|
51
66
|
result.log,
|
|
52
|
-
"## Instructions",
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
return
|
|
56
|
-
}
|
|
57
|
-
case "cancel": {
|
|
58
|
-
const parts = [
|
|
67
|
+
"## Instructions\n\n1. The CLI already marked the PR ready for review — end this iteration.",
|
|
68
|
+
]);
|
|
69
|
+
case "cancel":
|
|
70
|
+
return joinSections([
|
|
59
71
|
[`${heading} — ${result.reason}`, "", baseLine, summaryLine].join("\n"),
|
|
60
72
|
result.log,
|
|
61
|
-
"## Instructions",
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
return
|
|
65
|
-
}
|
|
66
|
-
case "escalate": {
|
|
67
|
-
const parts = [
|
|
73
|
+
"## Instructions\n\n1. Invoke `/loop cancel` via the Skill tool.\n2. Stop.",
|
|
74
|
+
]);
|
|
75
|
+
case "escalate":
|
|
76
|
+
return joinSections([
|
|
68
77
|
header,
|
|
69
78
|
result.escalate.humanMessage,
|
|
70
|
-
"## Instructions",
|
|
71
|
-
|
|
72
|
-
];
|
|
73
|
-
return parts.join("\n\n").replace(/\n\n\n+/g, "\n\n");
|
|
74
|
-
}
|
|
79
|
+
"## Instructions\n\n1. Invoke `/loop cancel` via the Skill tool.\n2. Stop — the PR needs human direction before monitoring can resume.",
|
|
80
|
+
]);
|
|
75
81
|
case "fix_code":
|
|
76
82
|
return formatFixCodeResult(header, result);
|
|
77
83
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project an IterateResult to a lean JSON shape for the default (non-verbose) output.
|
|
3
|
+
* Omits fields that are the trivial default (false, 0, empty) or state-gated fields
|
|
4
|
+
* outside the state where they are meaningful.
|
|
5
|
+
*/
|
|
6
|
+
export function projectIterateLean(result) {
|
|
7
|
+
const base = {
|
|
8
|
+
action: result.action,
|
|
9
|
+
pr: result.pr,
|
|
10
|
+
repo: result.repo || undefined,
|
|
11
|
+
status: result.status,
|
|
12
|
+
state: result.state,
|
|
13
|
+
mergeStateStatus: result.mergeStateStatus,
|
|
14
|
+
...(result.mergeStatus === "BLOCKED" &&
|
|
15
|
+
result.reviewDecision !== null && { reviewDecision: result.reviewDecision }),
|
|
16
|
+
...(result.copilotReviewInProgress && { copilotReviewInProgress: true }),
|
|
17
|
+
...(result.isDraft && { isDraft: true }),
|
|
18
|
+
summary: {
|
|
19
|
+
passing: result.summary.passing,
|
|
20
|
+
...(result.summary.skipped > 0 && { skipped: result.summary.skipped }),
|
|
21
|
+
...(result.summary.filtered > 0 && { filtered: result.summary.filtered }),
|
|
22
|
+
...(result.summary.inProgress > 0 && { inProgress: result.summary.inProgress }),
|
|
23
|
+
},
|
|
24
|
+
// remainingSeconds: only when the ready-delay timer is actively counting down
|
|
25
|
+
...(result.status === "READY" &&
|
|
26
|
+
result.remainingSeconds > 0 && {
|
|
27
|
+
remainingSeconds: result.remainingSeconds,
|
|
28
|
+
}),
|
|
29
|
+
...(result.baseBranch && { baseBranch: result.baseBranch }),
|
|
30
|
+
};
|
|
31
|
+
switch (result.action) {
|
|
32
|
+
case "cooldown":
|
|
33
|
+
return { ...base, log: result.log };
|
|
34
|
+
case "wait":
|
|
35
|
+
return { ...base, log: result.log };
|
|
36
|
+
case "cancel":
|
|
37
|
+
return { ...base, reason: result.reason, log: result.log };
|
|
38
|
+
case "mark_ready":
|
|
39
|
+
// drop markedReady — always true, redundant with action discriminator
|
|
40
|
+
return { ...base, log: result.log };
|
|
41
|
+
case "fix_code":
|
|
42
|
+
return {
|
|
43
|
+
...base,
|
|
44
|
+
...(result.checks.length > 0 && { checks: result.checks }),
|
|
45
|
+
...(result.cancelled.length > 0 && { cancelled: result.cancelled }),
|
|
46
|
+
fix: {
|
|
47
|
+
mode: result.fix.mode,
|
|
48
|
+
...(result.fix.threads.length > 0 && { threads: result.fix.threads }),
|
|
49
|
+
...(result.fix.actionableComments.length > 0 && {
|
|
50
|
+
actionableComments: result.fix.actionableComments,
|
|
51
|
+
}),
|
|
52
|
+
...(result.fix.reviewSummaryIds.length > 0 && {
|
|
53
|
+
reviewSummaryIds: result.fix.reviewSummaryIds,
|
|
54
|
+
}),
|
|
55
|
+
...(result.fix.surfacedApprovals.length > 0 && {
|
|
56
|
+
surfacedApprovals: result.fix.surfacedApprovals,
|
|
57
|
+
}),
|
|
58
|
+
...(result.fix.firstLookThreads.length > 0 && {
|
|
59
|
+
firstLookThreads: result.fix.firstLookThreads,
|
|
60
|
+
}),
|
|
61
|
+
...(result.fix.firstLookComments.length > 0 && {
|
|
62
|
+
firstLookComments: result.fix.firstLookComments,
|
|
63
|
+
}),
|
|
64
|
+
...(result.fix.checks.length > 0 && { checks: result.fix.checks }),
|
|
65
|
+
...(result.fix.changesRequestedReviews.length > 0 && {
|
|
66
|
+
changesRequestedReviews: result.fix.changesRequestedReviews,
|
|
67
|
+
}),
|
|
68
|
+
resolveCommand: result.fix.resolveCommand,
|
|
69
|
+
...(result.fix.instructions.length > 0 && { instructions: result.fix.instructions }),
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
case "escalate":
|
|
73
|
+
return {
|
|
74
|
+
...base,
|
|
75
|
+
escalate: {
|
|
76
|
+
...(result.escalate.triggers.length > 0 && { triggers: result.escalate.triggers }),
|
|
77
|
+
...(result.escalate.unresolvedThreads.length > 0 && {
|
|
78
|
+
unresolvedThreads: result.escalate.unresolvedThreads,
|
|
79
|
+
}),
|
|
80
|
+
...(result.escalate.ambiguousComments.length > 0 && {
|
|
81
|
+
ambiguousComments: result.escalate.ambiguousComments,
|
|
82
|
+
}),
|
|
83
|
+
...(result.escalate.changesRequestedReviews.length > 0 && {
|
|
84
|
+
changesRequestedReviews: result.escalate.changesRequestedReviews,
|
|
85
|
+
}),
|
|
86
|
+
...(result.escalate.attemptHistory &&
|
|
87
|
+
result.escalate.attemptHistory.length > 0 && {
|
|
88
|
+
attemptHistory: result.escalate.attemptHistory,
|
|
89
|
+
}),
|
|
90
|
+
suggestion: result.escalate.suggestion,
|
|
91
|
+
humanMessage: result.escalate.humanMessage,
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { renderLineRange, renderSuggestionBlock } from "./suggestion-renderer.mjs";
|
|
2
|
+
const BODY_PREVIEW_MAX = 100;
|
|
3
|
+
export function renderBodyPreview(body) {
|
|
4
|
+
const normalizedBody = body.replace(/\r\n?/g, "\n");
|
|
5
|
+
const firstLine = normalizedBody.split("\n")[0]?.trim() ?? "";
|
|
6
|
+
return firstLine.slice(0, BODY_PREVIEW_MAX);
|
|
7
|
+
}
|
|
8
|
+
export function renderFirstLookStatusTag(t) {
|
|
9
|
+
return t.autoResolved ? "[status: outdated, auto-resolved]" : `[status: ${t.firstLookStatus}]`;
|
|
10
|
+
}
|
|
11
|
+
export function renderThreadBullet(t, opts = {}) {
|
|
12
|
+
const link = t.url ? ` [↗](${t.url})` : "";
|
|
13
|
+
const loc = t.path
|
|
14
|
+
? `\`${t.path}:${renderLineRange(t.startLine ?? undefined, t.line ?? null)}\``
|
|
15
|
+
: "`(no location)`";
|
|
16
|
+
const suggestionMarker = t.suggestion ? " [suggestion]" : "";
|
|
17
|
+
const statusSuffix = opts.statusTag ? ` ${opts.statusTag}` : "";
|
|
18
|
+
const bulletLine = `- \`threadId=${t.id}\`${link} ${loc} (@${t.author})${suggestionMarker}${statusSuffix}: ${renderBodyPreview(t.body)}`;
|
|
19
|
+
if (t.suggestion && opts.renderSuggestion) {
|
|
20
|
+
return `${bulletLine}\n${renderSuggestionBlock(t.suggestion)}`;
|
|
21
|
+
}
|
|
22
|
+
return bulletLine;
|
|
23
|
+
}
|
|
24
|
+
export function renderCommentBullet(c, opts = {}) {
|
|
25
|
+
const link = c.url ? ` [↗](${c.url})` : "";
|
|
26
|
+
const statusSuffix = opts.statusTag ? ` ${opts.statusTag}` : "";
|
|
27
|
+
return `- \`commentId=${c.id}\`${link} (@${c.author})${statusSuffix}: ${renderBodyPreview(c.body)}`;
|
|
28
|
+
}
|
|
29
|
+
export function renderReviewBullet(r, opts = {}) {
|
|
30
|
+
const bodySuffix = opts.includeBody && r.body != null && r.body !== "" ? `: ${renderBodyPreview(r.body)}` : "";
|
|
31
|
+
return `- \`reviewId=${r.id}\` (@${r.author})${bodySuffix}`;
|
|
32
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { safeFence } from "./fence.mjs";
|
|
2
|
+
export function renderLineRange(startLine, endLine) {
|
|
3
|
+
if (endLine === null)
|
|
4
|
+
return "?";
|
|
5
|
+
if (startLine !== undefined && startLine !== endLine)
|
|
6
|
+
return `${startLine}-${endLine}`;
|
|
7
|
+
return String(endLine);
|
|
8
|
+
}
|
|
9
|
+
export function renderSuggestionBlock(s, indent = " ") {
|
|
10
|
+
const rangeLabel = s.startLine !== s.endLine ? `lines ${s.startLine}–${s.endLine}` : `line ${s.startLine}`;
|
|
11
|
+
const content = s.lines.join("\n");
|
|
12
|
+
const fence = safeFence(content);
|
|
13
|
+
const label = s.lines.length === 0
|
|
14
|
+
? `Replaces ${rangeLabel} with nothing:`
|
|
15
|
+
: s.lines.length === 1 && s.lines[0] === ""
|
|
16
|
+
? `Replaces ${rangeLabel} with a blank line:`
|
|
17
|
+
: `Replaces ${rangeLabel}:`;
|
|
18
|
+
return [
|
|
19
|
+
`${indent}${label}`,
|
|
20
|
+
`${indent}${fence}`,
|
|
21
|
+
...(content === ""
|
|
22
|
+
? []
|
|
23
|
+
: [
|
|
24
|
+
content
|
|
25
|
+
.split("\n")
|
|
26
|
+
.map((l) => `${indent}${l}`)
|
|
27
|
+
.join("\n"),
|
|
28
|
+
]),
|
|
29
|
+
`${indent}${fence}`,
|
|
30
|
+
].join("\n");
|
|
31
|
+
}
|
|
@@ -5,6 +5,8 @@ export function makeIterateResult(action = "wait") {
|
|
|
5
5
|
status: "IN_PROGRESS",
|
|
6
6
|
state: "OPEN",
|
|
7
7
|
mergeStateStatus: "BLOCKED",
|
|
8
|
+
mergeStatus: "BLOCKED",
|
|
9
|
+
reviewDecision: null,
|
|
8
10
|
copilotReviewInProgress: false,
|
|
9
11
|
isDraft: false,
|
|
10
12
|
shouldCancel: false,
|
|
@@ -17,8 +19,6 @@ export function makeIterateResult(action = "wait") {
|
|
|
17
19
|
return { ...base, action: "cooldown", log: "SKIP: CI still starting" };
|
|
18
20
|
if (action === "wait")
|
|
19
21
|
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
22
|
if (action === "mark_ready")
|
|
23
23
|
return { ...base, action: "mark_ready", markedReady: true, log: "MARKED READY: PR 42" };
|
|
24
24
|
if (action === "fix_code") {
|
|
@@ -29,9 +29,8 @@ export function makeIterateResult(action = "wait") {
|
|
|
29
29
|
mode: "rebase-and-push",
|
|
30
30
|
threads: [],
|
|
31
31
|
actionableComments: [],
|
|
32
|
-
noiseCommentIds: [],
|
|
33
32
|
reviewSummaryIds: [],
|
|
34
|
-
|
|
33
|
+
surfacedApprovals: [],
|
|
35
34
|
checks: [],
|
|
36
35
|
changesRequestedReviews: [],
|
|
37
36
|
resolveCommand: {
|
|
@@ -41,6 +40,8 @@ export function makeIterateResult(action = "wait") {
|
|
|
41
40
|
hasMutations: false,
|
|
42
41
|
},
|
|
43
42
|
instructions: ["End this iteration."],
|
|
43
|
+
firstLookThreads: [],
|
|
44
|
+
firstLookComments: [],
|
|
44
45
|
},
|
|
45
46
|
cancelled: [],
|
|
46
47
|
};
|
|
@@ -62,7 +63,7 @@ export function makeIterateResult(action = "wait") {
|
|
|
62
63
|
ambiguousComments: [],
|
|
63
64
|
changesRequestedReviews: [],
|
|
64
65
|
suggestion: "check manually",
|
|
65
|
-
humanMessage: "⚠️
|
|
66
|
+
humanMessage: "⚠️ /pr-shepherd:monitor paused — needs human direction",
|
|
66
67
|
},
|
|
67
68
|
};
|
|
68
69
|
}
|
package/bin/cli-parser.mjs
CHANGED
|
@@ -18,12 +18,14 @@
|
|
|
18
18
|
import { readFileSync } from "node:fs";
|
|
19
19
|
import { runCheck } from "./commands/check.mjs";
|
|
20
20
|
import { runResolveFetch, runResolveMutate } from "./commands/resolve.mjs";
|
|
21
|
+
import { runLogFile } from "./commands/log-file.mjs";
|
|
21
22
|
import { formatJson } from "./reporters/json.mjs";
|
|
22
23
|
import { formatText } from "./reporters/text.mjs";
|
|
23
24
|
import { parseCommonArgs, getFlag, hasFlag, parseList } from "./cli/args.mjs";
|
|
24
25
|
import { statusToExitCode } from "./cli/exit-codes.mjs";
|
|
25
26
|
import { formatFetchResult, formatMutateResult } from "./cli/formatters.mjs";
|
|
26
27
|
import { handleCommitSuggestion, handleIterate, handleMonitor, handleStatus, } from "./cli/handlers.mjs";
|
|
28
|
+
import { setupLog } from "./log/setup.mjs";
|
|
27
29
|
// ---------------------------------------------------------------------------
|
|
28
30
|
// Entry
|
|
29
31
|
// ---------------------------------------------------------------------------
|
|
@@ -34,6 +36,13 @@ export async function main(argv) {
|
|
|
34
36
|
process.stdout.write(`${readVersion()}\n`);
|
|
35
37
|
return;
|
|
36
38
|
}
|
|
39
|
+
// log-file must run before the stdout tee and log init to avoid recursion.
|
|
40
|
+
if (subcommand === "log-file") {
|
|
41
|
+
await handleLogFile(args.slice(1));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
// Initialize the per-worktree log and install a stdout tee.
|
|
45
|
+
await setupLog(argv);
|
|
37
46
|
switch (subcommand) {
|
|
38
47
|
case "check":
|
|
39
48
|
await handleCheck(args.slice(1));
|
|
@@ -55,7 +64,7 @@ export async function main(argv) {
|
|
|
55
64
|
break;
|
|
56
65
|
default:
|
|
57
66
|
process.stderr.write(`Unknown subcommand: ${subcommand ?? "(none)"}\n`);
|
|
58
|
-
process.stderr.write("Usage: pr-shepherd <check|resolve|commit-suggestion|iterate|monitor|status> [options]\n" +
|
|
67
|
+
process.stderr.write("Usage: pr-shepherd <check|resolve|commit-suggestion|iterate|monitor|status|log-file> [options]\n" +
|
|
59
68
|
" pr-shepherd --version | -v\n");
|
|
60
69
|
process.exitCode = 1;
|
|
61
70
|
return;
|
|
@@ -76,6 +85,21 @@ async function handleCheck(args) {
|
|
|
76
85
|
process.stdout.write(`${output}\n`);
|
|
77
86
|
process.exitCode = statusToExitCode(report.status);
|
|
78
87
|
}
|
|
88
|
+
async function handleLogFile(args) {
|
|
89
|
+
const jsonOut = args.some((a) => a === "--format=json") ||
|
|
90
|
+
(() => {
|
|
91
|
+
const idx = args.indexOf("--format");
|
|
92
|
+
return idx !== -1 && args[idx + 1] === "json";
|
|
93
|
+
})();
|
|
94
|
+
try {
|
|
95
|
+
const result = await runLogFile();
|
|
96
|
+
process.stdout.write(jsonOut ? `${JSON.stringify(result, null, 2)}\n` : `${result.path}\n`);
|
|
97
|
+
}
|
|
98
|
+
catch (e) {
|
|
99
|
+
process.stderr.write(`pr-shepherd: log-file: ${String(e)}\n`);
|
|
100
|
+
process.exitCode = 1;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
79
103
|
async function handleResolve(args) {
|
|
80
104
|
const { prNumber, global: globalOpts, extra } = parseCommonArgs(args);
|
|
81
105
|
const resolveThreadIds = parseList(getFlag(extra, "--resolve-thread-ids"));
|
|
@@ -91,7 +115,7 @@ async function handleResolve(args) {
|
|
|
91
115
|
const result = await runResolveFetch({ ...globalOpts, prNumber });
|
|
92
116
|
process.stdout.write(globalOpts.format === "json"
|
|
93
117
|
? `${JSON.stringify(result, null, 2)}\n`
|
|
94
|
-
: formatFetchResult(result));
|
|
118
|
+
: `${formatFetchResult(result)}\n`);
|
|
95
119
|
}
|
|
96
120
|
else {
|
|
97
121
|
const result = await runResolveMutate({
|
|
@@ -105,6 +129,6 @@ async function handleResolve(args) {
|
|
|
105
129
|
});
|
|
106
130
|
process.stdout.write(globalOpts.format === "json"
|
|
107
131
|
? `${JSON.stringify(result, null, 2)}\n`
|
|
108
|
-
: formatMutateResult(result));
|
|
132
|
+
: `${formatMutateResult(result)}\n`);
|
|
109
133
|
}
|
|
110
134
|
}
|
|
@@ -9,15 +9,18 @@ export function computeStatus(verdict, unresolvedThreads, unresolvedComments, me
|
|
|
9
9
|
return "FAILING";
|
|
10
10
|
if (verdict.anyInProgress)
|
|
11
11
|
return "IN_PROGRESS";
|
|
12
|
-
// BLOCKED
|
|
13
|
-
//
|
|
12
|
+
// BLOCKED with no remaining shepherd work — hand off via ready-delay regardless of why GitHub
|
|
13
|
+
// is BLOCKED (review pending, insufficient approvals, branch-protection rule, etc.).
|
|
14
|
+
// Requires hasChecks so that a PR with zero relevant checks (CI never started, or all
|
|
15
|
+
// filtered/skipped) doesn't prematurely trigger READY before any check has reported.
|
|
16
|
+
// copilotReviewInProgress is still excluded — a bot review is shepherd's problem, not a hand-off.
|
|
14
17
|
if (verdict.allPassed &&
|
|
18
|
+
verdict.hasChecks &&
|
|
15
19
|
unresolvedThreads === 0 &&
|
|
16
20
|
unresolvedComments === 0 &&
|
|
17
21
|
changesRequestedReviews === 0 &&
|
|
18
22
|
mergeStatus.status === "BLOCKED" &&
|
|
19
|
-
!mergeStatus.copilotReviewInProgress
|
|
20
|
-
mergeStatus.reviewDecision === "REVIEW_REQUIRED") {
|
|
23
|
+
!mergeStatus.copilotReviewInProgress) {
|
|
21
24
|
return "READY";
|
|
22
25
|
}
|
|
23
26
|
if (mergeStatus.status === "BLOCKED" ||
|