pr-shepherd 0.15.2 → 0.16.1
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/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +2 -2
- package/README.md +23 -93
- package/bin/cli/args.mjs +0 -1
- package/bin/cli/default-iterate.mjs +2 -7
- package/bin/cli/exit-codes.mjs +2 -4
- package/bin/cli/fix-formatter.mjs +8 -8
- package/bin/cli/handlers.mjs +3 -72
- package/bin/cli/iterate-formatter.mjs +8 -17
- package/bin/cli/iterate-instructions.mjs +13 -47
- package/bin/cli/iterate-lean.mjs +4 -11
- package/bin/cli/list-formatters.mjs +6 -3
- package/bin/cli-parser.iterate-fixtures.mjs +4 -7
- package/bin/cli-parser.mjs +4 -33
- package/bin/commands/check-status.mjs +2 -2
- package/bin/commands/check-terminal-report.mjs +35 -0
- package/bin/commands/check.mjs +12 -7
- package/bin/commands/commit-suggestion-instruction.mjs +23 -0
- package/bin/commands/iterate/check-instructions.mjs +1 -1
- package/bin/commands/iterate/classify.mjs +9 -4
- package/bin/commands/iterate/escalate.mjs +5 -6
- package/bin/commands/iterate/fix-code.mjs +3 -3
- package/bin/commands/iterate/helpers.mjs +0 -29
- package/bin/commands/iterate/index.mjs +18 -19
- package/bin/commands/iterate/render.mjs +5 -17
- package/bin/commands/resolve-instructions.mjs +2 -10
- package/bin/commands/resolve-mutate.mjs +16 -0
- package/bin/commands/resolve.mjs +6 -17
- package/bin/commands/shepherd-journal.mjs +1 -1
- package/bin/comments/minimize-policy.mjs +15 -0
- package/bin/comments/visible-comments.mjs +20 -0
- package/bin/config/load.mjs +10 -3
- package/bin/config.json +2 -3
- package/bin/github/batch-parsers.mjs +10 -0
- package/bin/github/gql/batch-pr.gql +6 -0
- package/bin/github/queries.mjs +0 -2
- package/bin/index.mjs +0 -2
- package/bin/merge-status/derive.mjs +6 -6
- package/bin/reporters/agent.mjs +10 -3
- package/bin/state/iterate-stall.mjs +9 -0
- package/package.json +5 -6
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
- package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +31 -26
- package/bin/commands/iterate.mjs +0 -2
- package/bin/commands/monitor.mjs +0 -139
- package/bin/commands/status.mjs +0 -126
- package/bin/github/gql/multi-pr-status-paged.gql +0 -31
- package/bin/reporters/check-instructions.mjs +0 -69
- package/bin/reporters/json.mjs +0 -10
- package/bin/reporters/text.mjs +0 -156
- package/plugin/skills/check/SKILL.md +0 -37
- package/plugin/skills/monitor/SKILL.md +0 -40
- package/plugin/skills/resolve/SKILL.md +0 -38
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { renderLineRange, renderSuggestionBlock } from "./suggestion-renderer.mjs";
|
|
2
2
|
const BODY_PREVIEW_MAX = 100;
|
|
3
|
+
export function renderAuthor(author, authorType) {
|
|
4
|
+
return authorType ? `@${author} · ${authorType}` : `@${author}`;
|
|
5
|
+
}
|
|
3
6
|
export function renderBodyPreview(body) {
|
|
4
7
|
const normalizedBody = body.replace(/\r\n?/g, "\n");
|
|
5
8
|
const firstLine = normalizedBody.split("\n")[0]?.trim() ?? "";
|
|
@@ -24,7 +27,7 @@ export function renderThreadBullet(t, opts = {}) {
|
|
|
24
27
|
: "`(no location)`";
|
|
25
28
|
const suggestionMarker = t.suggestion ? " [suggestion]" : "";
|
|
26
29
|
const statusSuffix = opts.statusTag ? ` ${opts.statusTag}` : "";
|
|
27
|
-
const bulletLine = `- \`threadId=${t.id}\`${link} ${loc} (
|
|
30
|
+
const bulletLine = `- \`threadId=${t.id}\`${link} ${loc} (${renderAuthor(t.author, t.authorType)})${suggestionMarker}${statusSuffix}: ${renderBodyPreview(t.body)}`;
|
|
28
31
|
if (t.suggestion && opts.renderSuggestion) {
|
|
29
32
|
return `${bulletLine}\n${renderSuggestionBlock(t.suggestion)}`;
|
|
30
33
|
}
|
|
@@ -33,11 +36,11 @@ export function renderThreadBullet(t, opts = {}) {
|
|
|
33
36
|
export function renderCommentBullet(c, opts = {}) {
|
|
34
37
|
const link = c.url ? ` [↗](${c.url})` : "";
|
|
35
38
|
const statusSuffix = opts.statusTag ? ` ${opts.statusTag}` : "";
|
|
36
|
-
return `- \`commentId=${c.id}\`${link} (
|
|
39
|
+
return `- \`commentId=${c.id}\`${link} (${renderAuthor(c.author, c.authorType)})${statusSuffix}: ${renderBodyPreview(c.body)}`;
|
|
37
40
|
}
|
|
38
41
|
export function renderReviewBullet(r, opts = {}) {
|
|
39
42
|
const bodySuffix = opts.includeBody && r.body != null && r.body !== "" ? `: ${renderBodyPreview(r.body)}` : "";
|
|
40
|
-
return `- \`reviewId=${r.id}\` (
|
|
43
|
+
return `- \`reviewId=${r.id}\` (${renderAuthor(r.author, r.authorType)})${bodySuffix}`;
|
|
41
44
|
}
|
|
42
45
|
export function renderReviewListSection(heading, items) {
|
|
43
46
|
if (items.length === 0)
|
|
@@ -7,7 +7,7 @@ export function makeIterateResult(action = "wait") {
|
|
|
7
7
|
mergeStateStatus: "BLOCKED",
|
|
8
8
|
mergeStatus: "BLOCKED",
|
|
9
9
|
reviewDecision: null,
|
|
10
|
-
|
|
10
|
+
blockingBotReviewInProgress: false,
|
|
11
11
|
isDraft: false,
|
|
12
12
|
shouldCancel: false,
|
|
13
13
|
remainingSeconds: 60,
|
|
@@ -15,8 +15,6 @@ export function makeIterateResult(action = "wait") {
|
|
|
15
15
|
baseBranch: "main",
|
|
16
16
|
checks: [],
|
|
17
17
|
};
|
|
18
|
-
if (action === "cooldown")
|
|
19
|
-
return { ...base, action: "cooldown", log: "SKIP: CI still starting" };
|
|
20
18
|
if (action === "wait")
|
|
21
19
|
return { ...base, action: "wait", log: "WAIT: 0 passing, 1 in-progress" };
|
|
22
20
|
if (action === "mark_ready")
|
|
@@ -26,7 +24,6 @@ export function makeIterateResult(action = "wait") {
|
|
|
26
24
|
...base,
|
|
27
25
|
action: "fix_code",
|
|
28
26
|
fix: {
|
|
29
|
-
mode: "rebase-and-push",
|
|
30
27
|
threads: [],
|
|
31
28
|
resolutionOnlyThreads: [],
|
|
32
29
|
actionableComments: [],
|
|
@@ -42,7 +39,7 @@ export function makeIterateResult(action = "wait") {
|
|
|
42
39
|
requiresDismissMessage: false,
|
|
43
40
|
hasMutations: false,
|
|
44
41
|
},
|
|
45
|
-
instructions: ["
|
|
42
|
+
instructions: ["Stop this iteration before the next tick."],
|
|
46
43
|
firstLookThreads: [],
|
|
47
44
|
firstLookComments: [],
|
|
48
45
|
inProgressRunIds: [],
|
|
@@ -55,7 +52,7 @@ export function makeIterateResult(action = "wait") {
|
|
|
55
52
|
...base,
|
|
56
53
|
action: "cancel",
|
|
57
54
|
reason: "ready-delay-elapsed",
|
|
58
|
-
log: "CANCEL: PR #42 — stopping
|
|
55
|
+
log: "CANCEL: PR #42 — stopping",
|
|
59
56
|
};
|
|
60
57
|
if (action === "escalate") {
|
|
61
58
|
return {
|
|
@@ -67,7 +64,7 @@ export function makeIterateResult(action = "wait") {
|
|
|
67
64
|
ambiguousComments: [],
|
|
68
65
|
changesRequestedReviews: [],
|
|
69
66
|
suggestion: "check manually",
|
|
70
|
-
humanMessage: "⚠️ /pr-shepherd:
|
|
67
|
+
humanMessage: "⚠️ /pr-shepherd:pr-shepherd paused — needs human direction",
|
|
71
68
|
},
|
|
72
69
|
};
|
|
73
70
|
}
|
package/bin/cli-parser.mjs
CHANGED
|
@@ -3,35 +3,26 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Usage:
|
|
5
5
|
* pr-shepherd --version
|
|
6
|
-
* pr-shepherd [PR] [--format text|json] [--
|
|
6
|
+
* pr-shepherd [PR] [--format text|json] [--ready-delay Nm]
|
|
7
7
|
* [--stall-timeout <duration>] [--no-auto-mark-ready]
|
|
8
8
|
* [--no-auto-cancel-actionable]
|
|
9
|
-
* pr-shepherd check [PR] [--format text|json]
|
|
10
9
|
* pr-shepherd resolve [PR] [--fetch] [--resolve-thread-ids A,B] [--minimize-comment-ids X,Y]
|
|
11
10
|
* [--dismiss-review-ids Q] [--message MSG] [--require-sha SHA]
|
|
12
11
|
* pr-shepherd commit-suggestion [PR] --thread-id ID [--message MSG] [--description DESC]
|
|
13
12
|
* [--dry-run] [--format text|json]
|
|
14
13
|
* (--message is required unless --dry-run is set)
|
|
15
|
-
* pr-shepherd iterate [PR] [--format text|json] [--
|
|
14
|
+
* pr-shepherd iterate [PR] [--format text|json] [--ready-delay Nm]
|
|
16
15
|
* [--stall-timeout <duration>] [--no-auto-mark-ready]
|
|
17
16
|
* [--no-auto-cancel-actionable]
|
|
18
|
-
* pr-shepherd monitor [PR] [--format text|json]
|
|
19
|
-
* pr-shepherd status PR1 [PR2 …]
|
|
20
17
|
*/
|
|
21
18
|
import { readFileSync } from "node:fs";
|
|
22
|
-
import { runCheck } from "./commands/check.mjs";
|
|
23
19
|
import { runResolveFetch, runResolveMutate } from "./commands/resolve.mjs";
|
|
24
20
|
import { runLogFile } from "./commands/log-file.mjs";
|
|
25
|
-
import { formatJson } from "./reporters/json.mjs";
|
|
26
|
-
import { formatText } from "./reporters/text.mjs";
|
|
27
21
|
import { parseCommonArgs, getFlag, hasFlag, parseList } from "./cli/args.mjs";
|
|
28
22
|
import { isDefaultIterateInvocation, validateDefaultIterateArgs } from "./cli/default-iterate.mjs";
|
|
29
|
-
import { statusToExitCode } from "./cli/exit-codes.mjs";
|
|
30
23
|
import { formatFetchResult, formatMutateResult } from "./cli/formatters.mjs";
|
|
31
|
-
import { handleCommitSuggestion, handleIterate
|
|
24
|
+
import { handleCommitSuggestion, handleIterate } from "./cli/handlers.mjs";
|
|
32
25
|
import { setupLog } from "./log/setup.mjs";
|
|
33
|
-
import { detectAgentRuntime } from "./agent-runtime.mjs";
|
|
34
|
-
import { loadConfig } from "./config/load.mjs";
|
|
35
26
|
// ---------------------------------------------------------------------------
|
|
36
27
|
// Entry
|
|
37
28
|
// ---------------------------------------------------------------------------
|
|
@@ -56,9 +47,6 @@ export async function main(argv) {
|
|
|
56
47
|
return;
|
|
57
48
|
}
|
|
58
49
|
switch (subcommand) {
|
|
59
|
-
case "check":
|
|
60
|
-
await handleCheck(args.slice(1));
|
|
61
|
-
break;
|
|
62
50
|
case "resolve":
|
|
63
51
|
await handleResolve(args.slice(1));
|
|
64
52
|
break;
|
|
@@ -68,15 +56,9 @@ export async function main(argv) {
|
|
|
68
56
|
case "iterate":
|
|
69
57
|
await handleIterate(args.slice(1));
|
|
70
58
|
break;
|
|
71
|
-
case "monitor":
|
|
72
|
-
await handleMonitor(args.slice(1));
|
|
73
|
-
break;
|
|
74
|
-
case "status":
|
|
75
|
-
await handleStatus(args.slice(1));
|
|
76
|
-
break;
|
|
77
59
|
default:
|
|
78
60
|
process.stderr.write(`Unknown subcommand: ${subcommand ?? "(none)"}\n`);
|
|
79
|
-
process.stderr.write("Usage: pr-shepherd <
|
|
61
|
+
process.stderr.write("Usage: pr-shepherd <resolve|commit-suggestion|iterate|log-file> [options]\n" +
|
|
80
62
|
" pr-shepherd --version | -v\n");
|
|
81
63
|
process.exitCode = 1;
|
|
82
64
|
return;
|
|
@@ -90,17 +72,6 @@ function readVersion() {
|
|
|
90
72
|
// ---------------------------------------------------------------------------
|
|
91
73
|
// Subcommand handlers
|
|
92
74
|
// ---------------------------------------------------------------------------
|
|
93
|
-
async function handleCheck(args) {
|
|
94
|
-
const { prNumber, global: globalOpts } = parseCommonArgs(args);
|
|
95
|
-
const runtime = detectAgentRuntime();
|
|
96
|
-
const cfg = loadConfig();
|
|
97
|
-
const report = await runCheck({ ...globalOpts, prNumber, autoResolve: false });
|
|
98
|
-
const output = globalOpts.format === "json"
|
|
99
|
-
? formatJson(report, { runtime, runner: cfg.cli?.runner })
|
|
100
|
-
: formatText(report, { runtime, runner: cfg.cli?.runner });
|
|
101
|
-
process.stdout.write(`${output}\n`);
|
|
102
|
-
process.exitCode = statusToExitCode(report.status);
|
|
103
|
-
}
|
|
104
75
|
async function handleLogFile(args) {
|
|
105
76
|
const jsonOut = args.some((a) => a === "--format=json") ||
|
|
106
77
|
(() => {
|
|
@@ -13,14 +13,14 @@ export function computeStatus(verdict, unresolvedThreads, unresolvedComments, me
|
|
|
13
13
|
// is BLOCKED (review pending, insufficient approvals, branch-protection rule, etc.).
|
|
14
14
|
// Requires hasChecks so that a PR with zero relevant checks (CI never started, or all
|
|
15
15
|
// filtered/skipped) doesn't prematurely trigger READY before any check has reported.
|
|
16
|
-
//
|
|
16
|
+
// blockingBotReviewInProgress is still excluded — a bot review is shepherd's problem, not a hand-off.
|
|
17
17
|
if (verdict.allPassed &&
|
|
18
18
|
verdict.hasChecks &&
|
|
19
19
|
unresolvedThreads === 0 &&
|
|
20
20
|
unresolvedComments === 0 &&
|
|
21
21
|
changesRequestedReviews === 0 &&
|
|
22
22
|
mergeStatus.status === "BLOCKED" &&
|
|
23
|
-
!mergeStatus.
|
|
23
|
+
!mergeStatus.blockingBotReviewInProgress) {
|
|
24
24
|
return "READY";
|
|
25
25
|
}
|
|
26
26
|
if (mergeStatus.status === "BLOCKED" ||
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export function buildTerminalReport(prNumber, repo, batchData, mergeStatus, status) {
|
|
2
|
+
return {
|
|
3
|
+
pr: prNumber,
|
|
4
|
+
nodeId: batchData.nodeId,
|
|
5
|
+
repo: `${repo.owner}/${repo.name}`,
|
|
6
|
+
status,
|
|
7
|
+
baseBranch: batchData.baseRefName,
|
|
8
|
+
mergeStatus,
|
|
9
|
+
checks: {
|
|
10
|
+
passing: [],
|
|
11
|
+
failing: [],
|
|
12
|
+
inProgress: [],
|
|
13
|
+
skipped: [],
|
|
14
|
+
filtered: [],
|
|
15
|
+
filteredNames: [],
|
|
16
|
+
blockedByFilteredCheck: false,
|
|
17
|
+
},
|
|
18
|
+
threads: {
|
|
19
|
+
actionable: [],
|
|
20
|
+
resolutionOnly: [],
|
|
21
|
+
autoResolved: [],
|
|
22
|
+
autoResolveErrors: [],
|
|
23
|
+
firstLook: [],
|
|
24
|
+
},
|
|
25
|
+
comments: {
|
|
26
|
+
actionable: [],
|
|
27
|
+
firstLook: [],
|
|
28
|
+
},
|
|
29
|
+
changesRequestedReviews: [],
|
|
30
|
+
reviewSummaries: [],
|
|
31
|
+
firstLookSummaries: [],
|
|
32
|
+
editedSummaries: [],
|
|
33
|
+
approvedReviews: [],
|
|
34
|
+
};
|
|
35
|
+
}
|
package/bin/commands/check.mjs
CHANGED
|
@@ -7,7 +7,9 @@ import { getOutdatedThreads } from "../comments/outdated.mjs";
|
|
|
7
7
|
import { autoResolveOutdated } from "../comments/resolve.mjs";
|
|
8
8
|
import { deriveMergeStatus } from "../merge-status/derive.mjs";
|
|
9
9
|
import { loadConfig } from "../config/load.mjs";
|
|
10
|
+
import { classifyVisibleComments } from "../comments/visible-comments.mjs";
|
|
10
11
|
import { computeStatus } from "./check-status.mjs";
|
|
12
|
+
import { buildTerminalReport } from "./check-terminal-report.mjs";
|
|
11
13
|
import { loadSeenMap, markSeen, classifyItem } from "../state/seen-comments.mjs";
|
|
12
14
|
export async function runCheck(opts) {
|
|
13
15
|
const repo = await getRepoInfo();
|
|
@@ -29,6 +31,10 @@ export async function runCheck(opts) {
|
|
|
29
31
|
mergeStateStatus: restState.mergeStateStatus ?? batchData.mergeStateStatus,
|
|
30
32
|
};
|
|
31
33
|
}
|
|
34
|
+
const mergeStatus = deriveMergeStatus(batchData);
|
|
35
|
+
if (mergeStatus.state === "MERGED" || mergeStatus.state === "CLOSED") {
|
|
36
|
+
return buildTerminalReport(prNumber, repo, batchData, mergeStatus, mergeStatus.state);
|
|
37
|
+
}
|
|
32
38
|
const startupFailureChecks = await fetchStartupFailureChecks(repo, batchData.headRefOid, prNumber);
|
|
33
39
|
const allChecks = mergeStartupFailureChecks(batchData.checks, startupFailureChecks);
|
|
34
40
|
const classifiedChecks = classifyChecks(allChecks);
|
|
@@ -41,7 +47,6 @@ export async function runCheck(opts) {
|
|
|
41
47
|
const triaged = failing.length > 0 && !opts.skipTriage ? await triageFailingChecks(failing, repo) : failing;
|
|
42
48
|
const stateKey = { owner: repo.owner, repo: repo.name, pr: prNumber };
|
|
43
49
|
const unresolvedThreads = batchData.reviewThreads.filter((t) => !t.isResolved);
|
|
44
|
-
const visibleComments = batchData.comments.filter((c) => !c.isMinimized);
|
|
45
50
|
const outdated = getOutdatedThreads(unresolvedThreads);
|
|
46
51
|
let autoResolved = [];
|
|
47
52
|
let autoResolveErrors = [];
|
|
@@ -56,6 +61,7 @@ export async function runCheck(opts) {
|
|
|
56
61
|
const minimizedThreadCandidates = batchData.reviewThreads.filter((t) => t.isMinimized && !t.isResolved && !t.isOutdated);
|
|
57
62
|
const minimizedCommentCandidates = batchData.comments.filter((c) => c.isMinimized);
|
|
58
63
|
const seenMap = await loadSeenMap(stateKey);
|
|
64
|
+
const visibleCommentClassification = classifyVisibleComments(batchData.comments, seenMap, config.iterate.minimizeComments);
|
|
59
65
|
const autoResolvedIds = new Set(autoResolved.map((t) => t.id));
|
|
60
66
|
const firstLookThreads = [
|
|
61
67
|
...outdatedCandidates.flatMap((t) => {
|
|
@@ -106,17 +112,15 @@ export async function runCheck(opts) {
|
|
|
106
112
|
await Promise.allSettled([
|
|
107
113
|
...firstLookThreads.map((t) => markSeen(stateKey, t.id, t.body)),
|
|
108
114
|
...firstLookComments.map((c) => markSeen(stateKey, c.id, c.body)),
|
|
115
|
+
...visibleCommentClassification.toMarkSeen.map((c) => markSeen(stateKey, c.id, c.body)),
|
|
109
116
|
...[...firstLookSummaries, ...editedSummaries].map((r) => markSeen(stateKey, r.id, r.body)),
|
|
110
117
|
]);
|
|
111
|
-
const actionableThreads = activeThreads;
|
|
112
118
|
const resolutionOnlyThreads = unresolvedThreads.filter((t) => !autoResolvedIds.has(t.id) && (t.isOutdated || t.isMinimized));
|
|
113
|
-
const actionableComments = visibleComments;
|
|
114
|
-
const mergeStatus = deriveMergeStatus(batchData);
|
|
115
119
|
const blockedByFilteredCheck = mergeStatus.status === "BLOCKED" &&
|
|
116
120
|
!verdict.anyFailing &&
|
|
117
121
|
!verdict.anyInProgress &&
|
|
118
122
|
verdict.filteredNames.length > 0;
|
|
119
|
-
const status = computeStatus(verdict,
|
|
123
|
+
const status = computeStatus(verdict, activeThreads.length + resolutionOnlyThreads.length, visibleCommentClassification.actionable.length, mergeStatus, batchData.changesRequestedReviews.length);
|
|
120
124
|
return {
|
|
121
125
|
pr: prNumber,
|
|
122
126
|
nodeId: batchData.nodeId,
|
|
@@ -134,14 +138,15 @@ export async function runCheck(opts) {
|
|
|
134
138
|
blockedByFilteredCheck,
|
|
135
139
|
},
|
|
136
140
|
threads: {
|
|
137
|
-
actionable:
|
|
141
|
+
actionable: activeThreads,
|
|
138
142
|
resolutionOnly: resolutionOnlyThreads,
|
|
139
143
|
autoResolved,
|
|
140
144
|
autoResolveErrors,
|
|
141
145
|
firstLook: firstLookThreads,
|
|
142
146
|
},
|
|
143
147
|
comments: {
|
|
144
|
-
actionable:
|
|
148
|
+
actionable: visibleCommentClassification.actionable,
|
|
149
|
+
minimizeIds: visibleCommentClassification.minimizeIds,
|
|
145
150
|
firstLook: firstLookComments,
|
|
146
151
|
},
|
|
147
152
|
changesRequestedReviews: batchData.changesRequestedReviews,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { buildPrShepherdCommand } from "../cli/runner.mjs";
|
|
2
|
+
/**
|
|
3
|
+
* Build the `commit-suggestion` instruction step for agent consumers.
|
|
4
|
+
* Shared between `fix_code` (iterate) and `resolve --fetch` paths.
|
|
5
|
+
* @param sectionName - The markdown section heading where suggestion threads appear
|
|
6
|
+
* (e.g. `"## Review threads"` for iterate, `"## Actionable Review Threads"` for resolve).
|
|
7
|
+
* @param includeDriftHint - Whether to add the trailing note about drift on failed apply.
|
|
8
|
+
*/
|
|
9
|
+
export function buildCommitSuggestionInstruction(prNumber, sectionName, includeDriftHint, runner) {
|
|
10
|
+
const command = buildPrShepherdCommand([
|
|
11
|
+
"commit-suggestion",
|
|
12
|
+
String(prNumber),
|
|
13
|
+
"--thread-id",
|
|
14
|
+
"<id>",
|
|
15
|
+
"--message",
|
|
16
|
+
"<one-sentence headline>",
|
|
17
|
+
"--format=json",
|
|
18
|
+
], { runner }).text;
|
|
19
|
+
const driftHint = includeDriftHint
|
|
20
|
+
? " If the patch fails to apply (drift since the suggestion was written), fall through to the manual fix step."
|
|
21
|
+
: " If the patch fails to apply, fall through to the manual-edit step.";
|
|
22
|
+
return `For each thread marked \`[suggestion]\` under \`${sectionName}\`: run \`${command}\` to retrieve the patch and suggested commit. The CLI does not mutate the working tree — apply the patch yourself (run \`git apply\` with the diff shown, or edit the file directly using the line range), then stage the listed file and run the suggested \`git commit\` from the \`## Instructions\` section. Include the thread ID in \`--resolve-thread-ids\` in the resolve command below (the thread is not auto-resolved).${driftHint} Do not retry the same command.`;
|
|
23
|
+
}
|
|
@@ -6,7 +6,7 @@ export function buildFailingCheckInstructions(checks) {
|
|
|
6
6
|
const externalChecks = checks.filter((c) => !c.runId && c.detailsUrl);
|
|
7
7
|
const bareChecks = checks.filter((c) => !c.runId && !c.detailsUrl);
|
|
8
8
|
if (failedRunIdChecks.length > 0) {
|
|
9
|
-
instructions.push(`For each failing check under \`## Failing checks\` with a run ID and no \`[conclusion: CANCELLED]\` or \`[conclusion: STARTUP_FAILURE]\` tag: run \`gh run view <runId> --log-failed\` to fetch the failing job's log
|
|
9
|
+
instructions.push(`For each failing check under \`## Failing checks\` with a run ID and no \`[conclusion: CANCELLED]\` or \`[conclusion: STARTUP_FAILURE]\` tag: run \`gh run view <runId> --log-failed\` to fetch the failing job's log. If the log shows a transient infrastructure failure (network timeout, runner setup crash, OOM kill), run \`gh run rerun <runId> --failed\`. If the log shows a real test/build failure, apply a code fix.`);
|
|
10
10
|
}
|
|
11
11
|
if (cancelledRunIdChecks.length > 0) {
|
|
12
12
|
instructions.push(`For each \`[conclusion: CANCELLED]\` bullet under \`## Failing checks\`: the run was cancelled outside Shepherd's control (manual cancel, newer push, concurrency-group eviction). Run \`gh run rerun <runId>\` only if the cancellation looks unintended; otherwise treat it as resolved by the superseding run. Do NOT confuse these with IDs under \`## Cancelled runs\` — those were cancelled by Shepherd itself.`);
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import { buildPrShepherdCommand } from "../../cli/runner.mjs";
|
|
2
|
-
|
|
2
|
+
import { shouldMinimizeAuthor } from "../../comments/minimize-policy.mjs";
|
|
3
|
+
export function classifyReviewSummaries(summaries, approvals, minimizeApprovals, minimizeComments = "all") {
|
|
3
4
|
// First-look and seen summaries go into the minimize mutation; edited summaries do NOT —
|
|
4
5
|
// they are already minimized server-side (body changed after minimize was applied).
|
|
5
6
|
// First-look bodies are rendered so the agent sees them before the minimize happens.
|
|
6
|
-
const minimizeIds = [...summaries.firstLook, ...summaries.seen]
|
|
7
|
+
const minimizeIds = [...summaries.firstLook, ...summaries.seen]
|
|
8
|
+
.filter((r) => shouldMinimizeAuthor(r.authorType, minimizeComments))
|
|
9
|
+
.map((r) => r.id);
|
|
7
10
|
if (minimizeApprovals) {
|
|
8
|
-
for (const r of approvals)
|
|
9
|
-
|
|
11
|
+
for (const r of approvals) {
|
|
12
|
+
if (shouldMinimizeAuthor(r.authorType, minimizeComments))
|
|
13
|
+
minimizeIds.push(r.id);
|
|
14
|
+
}
|
|
10
15
|
return {
|
|
11
16
|
minimizeIds,
|
|
12
17
|
firstLookSummaries: summaries.firstLook,
|
|
@@ -55,7 +55,7 @@ export function validateBaseBranch(raw) {
|
|
|
55
55
|
}
|
|
56
56
|
export function buildEscalateHumanMessage(escalate, pr) {
|
|
57
57
|
const lines = [];
|
|
58
|
-
lines.push("⚠️ /pr-shepherd:
|
|
58
|
+
lines.push("⚠️ /pr-shepherd:pr-shepherd paused — needs human direction");
|
|
59
59
|
lines.push("");
|
|
60
60
|
lines.push(`**Triggers:** ${escalate.triggers.map((t) => `\`${t}\``).join(", ")}`);
|
|
61
61
|
lines.push("");
|
|
@@ -80,18 +80,17 @@ export function buildEscalateHumanMessage(escalate, pr) {
|
|
|
80
80
|
lines.push(`- comment \`${c.id}\` (@${c.author}): ${firstLine}`);
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
|
-
if (escalate.
|
|
83
|
+
if (escalate.thrashHistory && escalate.thrashHistory.length > 0) {
|
|
84
84
|
lines.push("");
|
|
85
85
|
lines.push("## Fix attempts");
|
|
86
|
-
for (const a of escalate.
|
|
86
|
+
for (const a of escalate.thrashHistory) {
|
|
87
87
|
lines.push(`- thread \`${a.threadId}\` attempted ${a.attempts} times`);
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
lines.push("");
|
|
91
91
|
lines.push("---");
|
|
92
92
|
lines.push("");
|
|
93
|
-
lines.push(`
|
|
94
|
-
lines.push(`After fixing manually, rerun \`/pr-shepherd:monitor ${pr}\` to resume.`);
|
|
93
|
+
lines.push(`After fixing manually, rerun \`/pr-shepherd:pr-shepherd ${pr}\` to resume.`);
|
|
95
94
|
return lines.join("\n");
|
|
96
95
|
}
|
|
97
96
|
export function buildEscalateSuggestion(triggers, detail) {
|
|
@@ -104,7 +103,7 @@ export function buildEscalateSuggestion(triggers, detail) {
|
|
|
104
103
|
return `Could not determine the PR's base branch${reason} — refusing to emit a rebase that could force-push onto the wrong base. Run the rebase manually against the PR's real target branch.`;
|
|
105
104
|
}
|
|
106
105
|
if (triggers.includes("fix-thrash")) {
|
|
107
|
-
return "Same thread(s) attempted multiple times without resolution — fix manually then rerun /pr-shepherd:
|
|
106
|
+
return "Same thread(s) attempted multiple times without resolution — fix manually then rerun /pr-shepherd:pr-shepherd";
|
|
108
107
|
}
|
|
109
108
|
if (triggers.includes("pr-level-changes-requested")) {
|
|
110
109
|
return "Reviewer requested changes but left no inline comments — read the review and act manually";
|
|
@@ -23,7 +23,7 @@ export async function handleFixCode(ctx) {
|
|
|
23
23
|
unresolvedThreads: [...report.threads.actionable, ...report.threads.resolutionOnly].map(toAgentThread),
|
|
24
24
|
ambiguousComments: report.comments.actionable.map(toAgentComment),
|
|
25
25
|
changesRequestedReviews: report.changesRequestedReviews,
|
|
26
|
-
|
|
26
|
+
thrashHistory: escalateTriggers.thrashHistory,
|
|
27
27
|
suggestion: buildEscalateSuggestion(escalateTriggers.triggers),
|
|
28
28
|
};
|
|
29
29
|
return {
|
|
@@ -56,7 +56,8 @@ export async function handleFixCode(ctx) {
|
|
|
56
56
|
const inProgressRunIds = hasGuaranteedSupersedingPush
|
|
57
57
|
? buildInProgressRunIds(report, cancelledSet)
|
|
58
58
|
: [];
|
|
59
|
-
const
|
|
59
|
+
const commentMinimizeIds = report.comments.minimizeIds ?? actionableComments.map((c) => c.id);
|
|
60
|
+
const allCommentIds = [...commentMinimizeIds, ...reviewSummaryIds];
|
|
60
61
|
const resolveCommand = buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, changesRequestedReviews, checks, prNumber, cliRunner);
|
|
61
62
|
if (baseLookup.isFallback && (resolveCommand.requiresHeadSha || hasConflicts)) {
|
|
62
63
|
const fallbackEscalateBase = {
|
|
@@ -83,7 +84,6 @@ export async function handleFixCode(ctx) {
|
|
|
83
84
|
baseBranch: baseLookup.branch,
|
|
84
85
|
action: "fix_code",
|
|
85
86
|
fix: {
|
|
86
|
-
mode: "rebase-and-push",
|
|
87
87
|
threads,
|
|
88
88
|
resolutionOnlyThreads,
|
|
89
89
|
actionableComments,
|
|
@@ -58,15 +58,6 @@ export function buildRelevantChecks(report) {
|
|
|
58
58
|
});
|
|
59
59
|
return [...passing, ...failing];
|
|
60
60
|
}
|
|
61
|
-
export async function getLastCommitTime() {
|
|
62
|
-
try {
|
|
63
|
-
const { stdout } = await execFile("git", ["log", "-1", "--format=%ct", "HEAD"]);
|
|
64
|
-
return parseInt(stdout.trim(), 10);
|
|
65
|
-
}
|
|
66
|
-
catch {
|
|
67
|
-
return null;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
61
|
// Best-effort: cancelling a completed run is a no-op, not an error.
|
|
71
62
|
export async function tryCancelRun(runId, owner, repo) {
|
|
72
63
|
try {
|
|
@@ -118,23 +109,3 @@ export function buildWaitLog(base) {
|
|
|
118
109
|
}
|
|
119
110
|
return parts.join(" — ");
|
|
120
111
|
}
|
|
121
|
-
export function buildCooldownResult(prNumber, readyDelaySeconds) {
|
|
122
|
-
return {
|
|
123
|
-
action: "cooldown",
|
|
124
|
-
pr: prNumber,
|
|
125
|
-
repo: "",
|
|
126
|
-
status: "UNKNOWN",
|
|
127
|
-
state: "UNKNOWN",
|
|
128
|
-
mergeStateStatus: "UNKNOWN",
|
|
129
|
-
mergeStatus: "UNKNOWN",
|
|
130
|
-
reviewDecision: null,
|
|
131
|
-
copilotReviewInProgress: false,
|
|
132
|
-
isDraft: false,
|
|
133
|
-
shouldCancel: false,
|
|
134
|
-
remainingSeconds: readyDelaySeconds,
|
|
135
|
-
summary: { passing: 0, skipped: 0, filtered: 0, inProgress: 0 },
|
|
136
|
-
baseBranch: "",
|
|
137
|
-
checks: [],
|
|
138
|
-
log: "SKIP: CI still starting — waiting for first check to appear",
|
|
139
|
-
};
|
|
140
|
-
}
|
|
@@ -4,13 +4,13 @@ import { getCurrentPrNumber } from "../../github/client.mjs";
|
|
|
4
4
|
import { graphql } from "../../github/http.mjs";
|
|
5
5
|
import { MARK_PR_READY_MUTATION } from "../../github/queries.mjs";
|
|
6
6
|
import { loadConfig } from "../../config/load.mjs";
|
|
7
|
-
import {
|
|
7
|
+
import { getCurrentHeadSha, buildSummary, buildRelevantChecks, buildWaitLog } from "./helpers.mjs";
|
|
8
8
|
import { classifyReviewSummaries } from "./classify.mjs";
|
|
9
9
|
import { applyStallGuard } from "./stall.mjs";
|
|
10
|
+
import { clearStallState } from "../../state/iterate-stall.mjs";
|
|
10
11
|
import { handleFixCode } from "./fix-code.mjs";
|
|
11
12
|
export async function runIterate(opts) {
|
|
12
13
|
const config = loadConfig();
|
|
13
|
-
const cooldownSeconds = opts.cooldownSeconds ?? config.iterate.cooldownSeconds;
|
|
14
14
|
const readyDelaySeconds = opts.readyDelaySeconds ?? config.watch.readyDelayMinutes * 60;
|
|
15
15
|
const stallTimeoutSeconds = opts.stallTimeoutSeconds ?? config.iterate.stallTimeoutMinutes * 60;
|
|
16
16
|
const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
|
|
@@ -18,17 +18,18 @@ export async function runIterate(opts) {
|
|
|
18
18
|
throw new Error("No open PR found for current branch. Pass a PR number explicitly.");
|
|
19
19
|
}
|
|
20
20
|
const optsWithPr = { ...opts, prNumber };
|
|
21
|
-
const lastCommitTime = await getLastCommitTime();
|
|
22
|
-
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
23
|
-
if (lastCommitTime !== null && nowSeconds - lastCommitTime < cooldownSeconds) {
|
|
24
|
-
return buildCooldownResult(prNumber, readyDelaySeconds);
|
|
25
|
-
}
|
|
26
21
|
const report = await runCheck({
|
|
27
22
|
...optsWithPr,
|
|
28
23
|
autoResolve: config.actions.autoResolveOutdated,
|
|
29
24
|
});
|
|
25
|
+
const [repoOwner, repoName] = report.repo.split("/");
|
|
26
|
+
if (!repoOwner || !repoName) {
|
|
27
|
+
throw new Error(`Unexpected repo format: "${report.repo}" (expected "owner/name")`);
|
|
28
|
+
}
|
|
29
|
+
const stallKey = { owner: repoOwner, repo: repoName, pr: prNumber };
|
|
30
30
|
if (report.mergeStatus.state !== "OPEN") {
|
|
31
31
|
const state = report.mergeStatus.state.toLowerCase();
|
|
32
|
+
await clearStallState(stallKey);
|
|
32
33
|
return {
|
|
33
34
|
pr: report.pr,
|
|
34
35
|
repo: report.repo,
|
|
@@ -36,7 +37,7 @@ export async function runIterate(opts) {
|
|
|
36
37
|
mergeStateStatus: report.mergeStatus.mergeStateStatus,
|
|
37
38
|
mergeStatus: report.mergeStatus.status,
|
|
38
39
|
reviewDecision: report.mergeStatus.reviewDecision,
|
|
39
|
-
|
|
40
|
+
blockingBotReviewInProgress: report.mergeStatus.blockingBotReviewInProgress,
|
|
40
41
|
isDraft: report.mergeStatus.isDraft,
|
|
41
42
|
shouldCancel: true,
|
|
42
43
|
remainingSeconds: 0,
|
|
@@ -46,13 +47,9 @@ export async function runIterate(opts) {
|
|
|
46
47
|
checks: buildRelevantChecks(report),
|
|
47
48
|
action: "cancel",
|
|
48
49
|
reason: report.mergeStatus.state === "MERGED" ? "merged" : "closed",
|
|
49
|
-
log: `CANCEL: PR #${report.pr} is ${state} — stopping
|
|
50
|
+
log: `CANCEL: PR #${report.pr} is ${state} — stopping`,
|
|
50
51
|
};
|
|
51
52
|
}
|
|
52
|
-
const [repoOwner, repoName] = report.repo.split("/");
|
|
53
|
-
if (!repoOwner || !repoName) {
|
|
54
|
-
throw new Error(`Unexpected repo format: "${report.repo}" (expected "owner/name")`);
|
|
55
|
-
}
|
|
56
53
|
const isReady = report.status === "READY";
|
|
57
54
|
const readyState = await updateReadyDelay(report.pr, isReady, readyDelaySeconds, repoOwner, repoName);
|
|
58
55
|
const base = {
|
|
@@ -63,7 +60,7 @@ export async function runIterate(opts) {
|
|
|
63
60
|
mergeStateStatus: report.mergeStatus.mergeStateStatus,
|
|
64
61
|
mergeStatus: report.mergeStatus.status,
|
|
65
62
|
reviewDecision: report.mergeStatus.reviewDecision,
|
|
66
|
-
|
|
63
|
+
blockingBotReviewInProgress: report.mergeStatus.blockingBotReviewInProgress,
|
|
67
64
|
isDraft: report.mergeStatus.isDraft,
|
|
68
65
|
shouldCancel: readyState.shouldCancel,
|
|
69
66
|
remainingSeconds: readyState.remainingSeconds,
|
|
@@ -72,6 +69,7 @@ export async function runIterate(opts) {
|
|
|
72
69
|
checks: buildRelevantChecks(report),
|
|
73
70
|
};
|
|
74
71
|
if (readyState.shouldCancel) {
|
|
72
|
+
await clearStallState(stallKey);
|
|
75
73
|
let cancelNote;
|
|
76
74
|
if (base.mergeStatus !== "BLOCKED")
|
|
77
75
|
cancelNote = "has been ready for review";
|
|
@@ -85,23 +83,24 @@ export async function runIterate(opts) {
|
|
|
85
83
|
...base,
|
|
86
84
|
action: "cancel",
|
|
87
85
|
reason: "ready-delay-elapsed",
|
|
88
|
-
log: `CANCEL: PR #${base.pr} ${cancelNote} — ready-delay elapsed, stopping
|
|
86
|
+
log: `CANCEL: PR #${base.pr} ${cancelNote} — ready-delay elapsed, stopping`,
|
|
89
87
|
};
|
|
90
88
|
}
|
|
91
89
|
const headSha = (await getCurrentHeadSha()) ?? "unknown";
|
|
92
|
-
const stallKey = { owner: repoOwner, repo: repoName, pr: prNumber };
|
|
93
90
|
const { minimizeIds: reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, } = classifyReviewSummaries({
|
|
94
91
|
firstLook: report.firstLookSummaries,
|
|
95
92
|
seen: report.reviewSummaries,
|
|
96
93
|
edited: report.editedSummaries,
|
|
97
|
-
}, report.approvedReviews, config.iterate.minimizeApprovals);
|
|
94
|
+
}, report.approvedReviews, config.iterate.minimizeApprovals, config.iterate.minimizeComments);
|
|
98
95
|
const hasActionableWork = report.threads.actionable.length > 0 ||
|
|
99
96
|
report.threads.resolutionOnly.length > 0 ||
|
|
100
97
|
report.comments.actionable.length > 0 ||
|
|
101
98
|
report.changesRequestedReviews.length > 0 ||
|
|
102
99
|
report.checks.failing.length > 0 ||
|
|
103
100
|
report.mergeStatus.status === "CONFLICTS" ||
|
|
104
|
-
reviewSummaryIds.length > 0
|
|
101
|
+
reviewSummaryIds.length > 0 ||
|
|
102
|
+
firstLookSummaries.length > 0 ||
|
|
103
|
+
editedSummaries.length > 0;
|
|
105
104
|
if (hasActionableWork) {
|
|
106
105
|
return handleFixCode({
|
|
107
106
|
base,
|
|
@@ -122,7 +121,7 @@ export async function runIterate(opts) {
|
|
|
122
121
|
}
|
|
123
122
|
const canMarkReady = report.status === "READY" &&
|
|
124
123
|
report.mergeStatus.isDraft &&
|
|
125
|
-
!report.mergeStatus.
|
|
124
|
+
!report.mergeStatus.blockingBotReviewInProgress &&
|
|
126
125
|
!readyState.shouldCancel;
|
|
127
126
|
if (canMarkReady && !opts.noAutoMarkReady && config.actions.autoMarkReady) {
|
|
128
127
|
await graphql(MARK_PR_READY_MUTATION, { pullRequestId: report.nodeId });
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { renderShellCommand } from "../../cli/runner.mjs";
|
|
2
2
|
import { buildFailingCheckInstructions } from "./check-instructions.mjs";
|
|
3
3
|
import { SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE, SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEM_HEADINGS, buildShepherdJournalInstruction, } from "../shepherd-journal.mjs";
|
|
4
|
+
import { buildCommitSuggestionInstruction } from "../commit-suggestion-instruction.mjs";
|
|
4
5
|
export const FIX_INSTRUCTION_STOP_AFTER_PUSH = "Stop this iteration — CI needs time to run on the new push before the next tick.";
|
|
5
6
|
export const FIX_INSTRUCTION_STOP_BEFORE_NEXT_TICK = "Stop this iteration before the next tick.";
|
|
6
|
-
export const FIX_INSTRUCTION_END_ITERATION = "End this iteration.";
|
|
7
7
|
/**
|
|
8
8
|
* Render a resolve command as a shell snippet. Wraps `$DISMISS_MESSAGE`, `$HEAD_SHA`, and
|
|
9
9
|
* whitespace-bearing argv entries for placeholder substitution. `$HEAD_SHA` is appended separately
|
|
@@ -19,20 +19,11 @@ export function renderResolveCommand(rc) {
|
|
|
19
19
|
export function buildFixInstructions(threads, actionableComments, checks, reviews, baseBranch, resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], firstLookComments = [], firstLookSummaries = [], editedSummaries = [], inProgressRunIds = [], resolutionOnlyThreads = [], runner) {
|
|
20
20
|
const instructions = [];
|
|
21
21
|
if (inProgressRunIds.length > 0) {
|
|
22
|
-
instructions.push(`Cancel in-progress CI runs first: for each ID under \`## In-progress runs\`, run \`gh run cancel <id
|
|
22
|
+
instructions.push(`Cancel in-progress CI runs first: for each ID under \`## In-progress runs\`, run \`gh run cancel <id>\` before applying code fixes. If \`gh\` reports a run is already completed, ignore it and continue with the next ID.`);
|
|
23
23
|
}
|
|
24
24
|
const hasSuggestions = threads.some((t) => t.suggestion);
|
|
25
25
|
if (hasSuggestions) {
|
|
26
|
-
|
|
27
|
-
"commit-suggestion",
|
|
28
|
-
String(prNumber),
|
|
29
|
-
"--thread-id",
|
|
30
|
-
"<id>",
|
|
31
|
-
"--message",
|
|
32
|
-
"<one-sentence headline>",
|
|
33
|
-
"--format=json",
|
|
34
|
-
], { runner }).text;
|
|
35
|
-
instructions.push(`For each thread marked \`[suggestion]\` under \`## Review threads\`: run \`${commitSuggestionCommand}\` to retrieve the patch and suggested commit. The CLI does not mutate the working tree — apply the patch yourself (run \`git apply\` with the diff shown, or edit the file directly using the line range), then stage the listed file and run the suggested \`git commit\` from the \`## Instructions\` section. Include the thread ID in \`--resolve-thread-ids\` in the \`resolve:\` command below (the thread is not auto-resolved). If the patch fails to apply, fall through to the manual-edit step. Do not retry the same command.`);
|
|
26
|
+
instructions.push(buildCommitSuggestionInstruction(prNumber, "## Review threads", false, runner));
|
|
36
27
|
}
|
|
37
28
|
if (threads.length > 0 || actionableComments.length > 0) {
|
|
38
29
|
const suggestionFallback = hasSuggestions
|
|
@@ -97,11 +88,8 @@ export function buildFixInstructions(threads, actionableComments, checks, review
|
|
|
97
88
|
if (needsPush) {
|
|
98
89
|
instructions.push(FIX_INSTRUCTION_STOP_AFTER_PUSH);
|
|
99
90
|
}
|
|
100
|
-
else if (resolveCommand.hasMutations) {
|
|
101
|
-
instructions.push(FIX_INSTRUCTION_STOP_BEFORE_NEXT_TICK);
|
|
102
|
-
}
|
|
103
91
|
else {
|
|
104
|
-
instructions.push(
|
|
92
|
+
instructions.push(FIX_INSTRUCTION_STOP_BEFORE_NEXT_TICK);
|
|
105
93
|
}
|
|
106
94
|
return instructions;
|
|
107
95
|
}
|