pr-shepherd 0.16.4 → 0.18.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 +21 -13
- package/bin/cli/args.mjs +2 -0
- package/bin/cli/clean-formatter.mjs +20 -0
- package/bin/cli/default-iterate.mjs +1 -1
- package/bin/cli/duration-flag.mjs +22 -0
- package/bin/cli/exit-codes.mjs +14 -0
- package/bin/cli/fix-formatter.mjs +4 -12
- package/bin/cli/formatters.mjs +6 -10
- package/bin/cli/handlers.mjs +72 -36
- package/bin/cli/iterate-emitter.mjs +19 -0
- package/bin/cli/iterate-flags.mjs +21 -0
- package/bin/cli/iterate-formatter.mjs +50 -13
- package/bin/cli/iterate-instructions.mjs +13 -17
- package/bin/cli/iterate-lean.mjs +9 -12
- package/bin/cli/list-formatters.mjs +21 -1
- package/bin/cli/poll-handler.mjs +42 -0
- package/bin/cli/runner.mjs +13 -3
- package/bin/cli-parser.clean.test-support.mjs +45 -0
- package/bin/cli-parser.iterate-fix.test-support.mjs +0 -4
- package/bin/cli-parser.iterate-fixtures.mjs +5 -2
- package/bin/cli-parser.iterate.test-support.mjs +0 -4
- package/bin/cli-parser.mjs +13 -2
- package/bin/commands/check-terminal-report.mjs +1 -0
- package/bin/commands/check.mjs +1 -0
- package/bin/commands/check.test-support.mjs +1 -0
- package/bin/commands/clean.mjs +156 -0
- package/bin/commands/clean.test-support.mjs +48 -0
- package/bin/commands/commit-suggestion.apply.test-support.mjs +1 -0
- package/bin/commands/commit-suggestion.test-support.mjs +1 -0
- package/bin/commands/iterate/check-instructions.mjs +19 -17
- package/bin/commands/iterate/classify.mjs +37 -9
- package/bin/commands/iterate/escalate.mjs +26 -28
- package/bin/commands/iterate/fix-code.mjs +28 -6
- package/bin/commands/iterate/index.mjs +2 -0
- package/bin/commands/iterate/render.mjs +51 -40
- package/bin/commands/iterate-test-support.mjs +1 -0
- package/bin/commands/poll.mjs +32 -0
- package/bin/commands/poll.test-support.mjs +77 -0
- package/bin/commands/resolve-instructions.mjs +2 -5
- package/bin/comments/resolve.mjs +70 -11
- package/bin/github/batch-parser-helpers.mjs +38 -0
- package/bin/github/batch-parsers.mjs +12 -38
- package/bin/github/client.mjs +10 -1
- package/bin/github/gql/batch-pr.gql +9 -0
- package/bin/state/base.mjs +2 -1
- package/bin/state/seen-comments.mjs +29 -15
- package/package.json +1 -1
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
- package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +16 -5
- package/bin/agent-runtime.mjs +0 -7
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import { buildPrShepherdCommand } from "../../cli/runner.mjs";
|
|
2
2
|
import { shouldMinimizeAuthor } from "../../comments/minimize-policy.mjs";
|
|
3
|
+
function dedupeIds(ids) {
|
|
4
|
+
const seen = new Set();
|
|
5
|
+
const out = [];
|
|
6
|
+
for (const id of ids) {
|
|
7
|
+
if (seen.has(id))
|
|
8
|
+
continue;
|
|
9
|
+
seen.add(id);
|
|
10
|
+
out.push(id);
|
|
11
|
+
}
|
|
12
|
+
return out;
|
|
13
|
+
}
|
|
3
14
|
export function classifyReviewSummaries(summaries, approvals, minimizeApprovals, minimizeComments = "all") {
|
|
4
15
|
// First-look and seen summaries go into the minimize mutation; edited summaries do NOT —
|
|
5
16
|
// they are already minimized server-side (body changed after minimize was applied).
|
|
@@ -28,26 +39,43 @@ export function classifyReviewSummaries(summaries, approvals, minimizeApprovals,
|
|
|
28
39
|
}
|
|
29
40
|
export function buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, reviews, checks, prNumber, runner) {
|
|
30
41
|
const argv = buildPrShepherdCommand(["resolve", String(prNumber)], { runner }).argv;
|
|
31
|
-
const
|
|
42
|
+
const resolveThreadIds = dedupeIds(threads.map((t) => t.id));
|
|
43
|
+
const threadIds = dedupeIds([...resolveThreadIds, ...resolutionOnlyThreads.map((t) => t.id)]);
|
|
32
44
|
if (threadIds.length > 0) {
|
|
33
45
|
argv.push("--resolve-thread-ids", threadIds.join(","));
|
|
34
46
|
}
|
|
35
47
|
if (allCommentIds.length > 0) {
|
|
36
48
|
argv.push("--minimize-comment-ids", allCommentIds.join(","));
|
|
37
49
|
}
|
|
38
|
-
const
|
|
50
|
+
const commentIdSet = new Set(allCommentIds);
|
|
51
|
+
const filteredReviewIds = [];
|
|
52
|
+
const droppedDismissReviewIds = [];
|
|
53
|
+
for (const review of reviews) {
|
|
54
|
+
if (commentIdSet.has(review.id))
|
|
55
|
+
droppedDismissReviewIds.push(review.id);
|
|
56
|
+
else
|
|
57
|
+
filteredReviewIds.push(review.id);
|
|
58
|
+
}
|
|
59
|
+
const hasDismiss = filteredReviewIds.length > 0;
|
|
39
60
|
if (hasDismiss) {
|
|
40
|
-
argv.push("--dismiss-review-ids",
|
|
61
|
+
argv.push("--dismiss-review-ids", filteredReviewIds.join(","));
|
|
41
62
|
argv.push("--message", "$DISMISS_MESSAGE");
|
|
42
63
|
}
|
|
43
|
-
// A push is required when threads, CI failures, or changes-requested reviews are present — the
|
|
44
|
-
// CLI knows those imply code edits. Comments are surfaced for the agent to evaluate; the CLI
|
|
45
|
-
// cannot know whether a given comment will require a push, so comments are excluded here.
|
|
46
|
-
const requiresHeadSha = threads.length > 0 || checks.length > 0 || reviews.length > 0;
|
|
47
64
|
// hasMutations = we appended at least one of --resolve-thread-ids,
|
|
48
65
|
// --minimize-comment-ids, or --dismiss-review-ids. Returned explicitly
|
|
49
66
|
// (rather than derived from argv.length) so callers don't couple to the
|
|
50
67
|
// base-argv shape.
|
|
51
|
-
const hasMutations = threadIds.length > 0 || allCommentIds.length > 0 ||
|
|
52
|
-
|
|
68
|
+
const hasMutations = threadIds.length > 0 || allCommentIds.length > 0 || filteredReviewIds.length > 0;
|
|
69
|
+
// `requiresHeadSha` is only added when this resolve command includes a
|
|
70
|
+
// mutation that can race with a moving HEAD: resolving actionable threads,
|
|
71
|
+
// dismissing CHANGES_REQUESTED reviews, or addressing failing checks.
|
|
72
|
+
const hasCodeMutations = hasMutations && (threads.length > 0 || checks.length > 0 || filteredReviewIds.length > 0);
|
|
73
|
+
const requiresHeadSha = hasCodeMutations;
|
|
74
|
+
return {
|
|
75
|
+
argv,
|
|
76
|
+
requiresHeadSha,
|
|
77
|
+
requiresDismissMessage: hasDismiss,
|
|
78
|
+
...(droppedDismissReviewIds.length > 0 ? { droppedDismissReviewIds } : undefined),
|
|
79
|
+
hasMutations,
|
|
80
|
+
};
|
|
53
81
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { loadConfig } from "../../config/load.mjs";
|
|
2
|
-
export function checkEscalateTriggers(actionableThreads,
|
|
2
|
+
export function checkEscalateTriggers(actionableThreads, threadAttempts) {
|
|
3
3
|
const triggers = [];
|
|
4
4
|
const maxAttempts = loadConfig().iterate.fixAttemptsPerThread;
|
|
5
5
|
// Trigger 1: fix thrash — same thread dispatched too many times without resolving.
|
|
@@ -7,17 +7,7 @@ export function checkEscalateTriggers(actionableThreads, resolutionOnlyThreads,
|
|
|
7
7
|
if (thrashThreads.length > 0) {
|
|
8
8
|
triggers.push("fix-thrash");
|
|
9
9
|
}
|
|
10
|
-
// Trigger 2:
|
|
11
|
-
// Skip when there are merge conflicts — fix_code handles conflict resolution, not escalation.
|
|
12
|
-
if (changesRequestedReviews.length > 0 &&
|
|
13
|
-
actionableThreads.length === 0 &&
|
|
14
|
-
resolutionOnlyThreads.length === 0 &&
|
|
15
|
-
actionableComments.length === 0 &&
|
|
16
|
-
failingChecks.length === 0 &&
|
|
17
|
-
!hasConflicts) {
|
|
18
|
-
triggers.push("pr-level-changes-requested");
|
|
19
|
-
}
|
|
20
|
-
// Trigger 3: actionable thread has no file/line — cannot locate code to edit.
|
|
10
|
+
// Trigger 2: actionable thread has no file/line — cannot locate code to edit.
|
|
21
11
|
const unlocatable = actionableThreads.filter((t) => t.path === null || t.line === null);
|
|
22
12
|
if (unlocatable.length > 0) {
|
|
23
13
|
triggers.push("thread-missing-location");
|
|
@@ -55,7 +45,7 @@ export function validateBaseBranch(raw) {
|
|
|
55
45
|
}
|
|
56
46
|
export function buildEscalateHumanMessage(escalate, pr) {
|
|
57
47
|
const lines = [];
|
|
58
|
-
lines.push("⚠️ /pr-shepherd:pr-shepherd paused —
|
|
48
|
+
lines.push("⚠️ /pr-shepherd:pr-shepherd paused — manual intervention required");
|
|
59
49
|
lines.push("");
|
|
60
50
|
lines.push(`**Triggers:** ${escalate.triggers.map((t) => `\`${t}\``).join(", ")}`);
|
|
61
51
|
lines.push("");
|
|
@@ -66,23 +56,34 @@ export function buildEscalateHumanMessage(escalate, pr) {
|
|
|
66
56
|
if (hasItems) {
|
|
67
57
|
lines.push("");
|
|
68
58
|
lines.push("## Items needing attention");
|
|
59
|
+
lines.push("");
|
|
69
60
|
for (const t of escalate.unresolvedThreads) {
|
|
70
61
|
const loc = t.path ? `\`${t.path}:${t.line ?? "?"}\`` : "(no location)";
|
|
71
|
-
|
|
72
|
-
lines.push(
|
|
62
|
+
lines.push(`- thread \`${t.id}\` — ${loc} (@${t.author}):`);
|
|
63
|
+
lines.push("");
|
|
64
|
+
for (const bodyLine of t.body.split("\n"))
|
|
65
|
+
lines.push(` > ${bodyLine}`);
|
|
66
|
+
lines.push("");
|
|
73
67
|
}
|
|
74
68
|
for (const r of escalate.changesRequestedReviews) {
|
|
75
|
-
|
|
76
|
-
lines.push(
|
|
69
|
+
lines.push(`- review \`${r.id}\` (@${r.author}):`);
|
|
70
|
+
lines.push("");
|
|
71
|
+
for (const bodyLine of r.body.split("\n"))
|
|
72
|
+
lines.push(` > ${bodyLine}`);
|
|
73
|
+
lines.push("");
|
|
77
74
|
}
|
|
78
75
|
for (const c of escalate.ambiguousComments) {
|
|
79
|
-
|
|
80
|
-
lines.push(
|
|
76
|
+
lines.push(`- comment \`${c.id}\` (@${c.author}):`);
|
|
77
|
+
lines.push("");
|
|
78
|
+
for (const bodyLine of c.body.split("\n"))
|
|
79
|
+
lines.push(` > ${bodyLine}`);
|
|
80
|
+
lines.push("");
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
if (escalate.thrashHistory && escalate.thrashHistory.length > 0) {
|
|
84
84
|
lines.push("");
|
|
85
85
|
lines.push("## Fix attempts");
|
|
86
|
+
lines.push("");
|
|
86
87
|
for (const a of escalate.thrashHistory) {
|
|
87
88
|
lines.push(`- thread \`${a.threadId}\` attempted ${a.attempts} times`);
|
|
88
89
|
}
|
|
@@ -90,26 +91,23 @@ export function buildEscalateHumanMessage(escalate, pr) {
|
|
|
90
91
|
lines.push("");
|
|
91
92
|
lines.push("---");
|
|
92
93
|
lines.push("");
|
|
93
|
-
lines.push(`After
|
|
94
|
+
lines.push(`After completing manual fixes (and pushing if required), rerun \`/pr-shepherd:pr-shepherd ${pr}\` to resume.`);
|
|
94
95
|
return lines.join("\n");
|
|
95
96
|
}
|
|
96
97
|
export function buildEscalateSuggestion(triggers, detail) {
|
|
97
98
|
if (triggers.includes("stall-timeout")) {
|
|
98
99
|
const mins = detail ?? "30";
|
|
99
|
-
return `No progress detected for ${mins} minute${parseInt(mins, 10) === 1 ? "" : "s"} — state has not changed.
|
|
100
|
+
return `No progress detected for ${mins} minute${parseInt(mins, 10) === 1 ? "" : "s"} — state has not changed. This is a manual checkpoint: inspect the PR and apply a manual fix before resuming.`;
|
|
100
101
|
}
|
|
101
102
|
if (triggers.includes("base-branch-unknown")) {
|
|
102
103
|
const reason = detail ? ` (${detail})` : "";
|
|
103
|
-
return `Could not determine the PR's base branch${reason} —
|
|
104
|
+
return `Could not determine the PR's base branch${reason} — automated rebases are paused because branch safety is unclear. Run the rebase manually against the PR's real target branch.`;
|
|
104
105
|
}
|
|
105
106
|
if (triggers.includes("fix-thrash")) {
|
|
106
|
-
return "Same thread(s)
|
|
107
|
-
}
|
|
108
|
-
if (triggers.includes("pr-level-changes-requested")) {
|
|
109
|
-
return "Reviewer requested changes but left no inline comments — read the review and act manually";
|
|
107
|
+
return "Same thread(s) reached the automated attempt limit — treat this as a manual handoff. Apply the fix by hand.";
|
|
110
108
|
}
|
|
111
109
|
if (triggers.includes("thread-missing-location")) {
|
|
112
|
-
return "Review thread has no file/line reference —
|
|
110
|
+
return "Review thread has no file/line reference — automated location routing failed and manual handling is required.";
|
|
113
111
|
}
|
|
114
|
-
return "Ambiguous state —
|
|
112
|
+
return "Ambiguous state — automated handling cannot proceed safely. Inspect the PR and act manually.";
|
|
115
113
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/* eslint-disable max-lines */
|
|
1
2
|
import { readFixAttempts, writeFixAttempts } from "../../state/fix-attempts.mjs";
|
|
2
3
|
import { toAgentThread, toAgentComment, toAgentChecks } from "../../reporters/agent.mjs";
|
|
3
4
|
import { checkEscalateTriggers, validateBaseBranch, buildEscalateSuggestion, buildEscalateHumanMessage, } from "./escalate.mjs";
|
|
@@ -16,7 +17,7 @@ export async function handleFixCode(ctx) {
|
|
|
16
17
|
currentAttempts[t.id] = (currentAttempts[t.id] ?? 0) + 1;
|
|
17
18
|
}
|
|
18
19
|
}
|
|
19
|
-
const escalateTriggers = checkEscalateTriggers(report.threads.actionable,
|
|
20
|
+
const escalateTriggers = checkEscalateTriggers(report.threads.actionable, currentAttempts);
|
|
20
21
|
if (escalateTriggers.triggers.length > 0) {
|
|
21
22
|
const escalateBase = {
|
|
22
23
|
triggers: escalateTriggers.triggers,
|
|
@@ -52,14 +53,35 @@ export async function handleFixCode(ctx) {
|
|
|
52
53
|
const checks = toAgentChecks(failingChecks);
|
|
53
54
|
const { changesRequestedReviews } = report;
|
|
54
55
|
const hasConflicts = report.mergeStatus.status === "CONFLICTS";
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
// Only surface in-progress runs when a push is plausible — resolution-only and
|
|
57
|
+
// summary-only iterations have no path to a push, so listing runs would prompt
|
|
58
|
+
// unnecessary cancellation.
|
|
59
|
+
const pushLikely = threads.length > 0 ||
|
|
60
|
+
checks.length > 0 ||
|
|
61
|
+
hasConflicts ||
|
|
62
|
+
changesRequestedReviews.length > 0 ||
|
|
63
|
+
actionableComments.length > 0;
|
|
64
|
+
const inProgressRunIds = pushLikely ? buildInProgressRunIds(report, cancelledSet) : [];
|
|
59
65
|
const commentMinimizeIds = report.comments.minimizeIds ?? actionableComments.map((c) => c.id);
|
|
60
66
|
const allCommentIds = [...commentMinimizeIds, ...reviewSummaryIds];
|
|
61
67
|
const resolveCommand = buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, changesRequestedReviews, checks, prNumber, cliRunner);
|
|
62
|
-
|
|
68
|
+
const overlappingReviewIds = resolveCommand.droppedDismissReviewIds ?? [];
|
|
69
|
+
if (overlappingReviewIds.length > 0) {
|
|
70
|
+
process.stderr.write(`pr-shepherd: resolve command overlap: ${overlappingReviewIds.length} ` +
|
|
71
|
+
`review IDs were also in minimize/comment IDs and were dropped from --dismiss-review-ids: ` +
|
|
72
|
+
`${overlappingReviewIds.join(", ")}\n`);
|
|
73
|
+
}
|
|
74
|
+
// Safety: if the base branch is unknown, escalate when a push is plausible — the agent
|
|
75
|
+
// would need the correct base to rebase safely. This is a conservative guard, not a
|
|
76
|
+
// prediction that the agent *will* push. Intentionally broader than `pushLikely` above:
|
|
77
|
+
// resolution-only threads also need a known base in case the agent does push.
|
|
78
|
+
const pushIsPlausible = threads.length > 0 ||
|
|
79
|
+
checks.length > 0 ||
|
|
80
|
+
hasConflicts ||
|
|
81
|
+
changesRequestedReviews.length > 0 ||
|
|
82
|
+
actionableComments.length > 0 ||
|
|
83
|
+
resolutionOnlyThreads.length > 0;
|
|
84
|
+
if (baseLookup.isFallback && pushIsPlausible) {
|
|
63
85
|
const fallbackEscalateBase = {
|
|
64
86
|
triggers: ["base-branch-unknown"],
|
|
65
87
|
unresolvedThreads: [...threads, ...resolutionOnlyThreads.map(toAgentThread)],
|
|
@@ -45,6 +45,7 @@ export async function runIterate(opts) {
|
|
|
45
45
|
state: report.mergeStatus.state,
|
|
46
46
|
summary: buildSummary(report),
|
|
47
47
|
baseBranch: report.baseBranch,
|
|
48
|
+
branchProtection: report.branchProtection,
|
|
48
49
|
checks: buildRelevantChecks(report),
|
|
49
50
|
action: "cancel",
|
|
50
51
|
reason: report.mergeStatus.state === "MERGED" ? "merged" : "closed",
|
|
@@ -84,6 +85,7 @@ export async function runIterate(opts) {
|
|
|
84
85
|
remainingSeconds: readyState.remainingSeconds,
|
|
85
86
|
summary: buildSummary(report),
|
|
86
87
|
baseBranch: report.baseBranch,
|
|
88
|
+
branchProtection: report.branchProtection,
|
|
87
89
|
checks: buildRelevantChecks(report),
|
|
88
90
|
};
|
|
89
91
|
if (readyState.shouldCancel) {
|
|
@@ -2,12 +2,11 @@ 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
4
|
import { buildCommitSuggestionInstruction } from "../commit-suggestion-instruction.mjs";
|
|
5
|
-
export const
|
|
6
|
-
export const FIX_INSTRUCTION_STOP_BEFORE_NEXT_TICK = "Stop this iteration before the next tick.";
|
|
5
|
+
export const FIX_INSTRUCTION_STOP = "Stop this iteration — if you pushed new commits, CI needs time before the next tick; otherwise stop before the next tick.";
|
|
7
6
|
/**
|
|
8
7
|
* Render a resolve command as a shell snippet. Wraps `$DISMISS_MESSAGE`, `$HEAD_SHA`, and
|
|
9
|
-
* whitespace-bearing argv entries for placeholder substitution. `$HEAD_SHA` is
|
|
10
|
-
* when `requiresHeadSha` is set.
|
|
8
|
+
* whitespace-bearing argv entries for placeholder substitution. `$HEAD_SHA` is never in `argv` —
|
|
9
|
+
* `renderResolveCommand` appends `--require-sha "$HEAD_SHA"` when `requiresHeadSha` is set.
|
|
11
10
|
*/
|
|
12
11
|
export function renderResolveCommand(rc) {
|
|
13
12
|
const parts = [...rc.argv];
|
|
@@ -16,10 +15,41 @@ export function renderResolveCommand(rc) {
|
|
|
16
15
|
}
|
|
17
16
|
return renderShellCommand(parts);
|
|
18
17
|
}
|
|
19
|
-
export function buildFixInstructions(threads, actionableComments, checks,
|
|
18
|
+
export function buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseBranch, resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], firstLookComments = [], firstLookSummaries = [], editedSummaries = [], inProgressRunIds = [], resolutionOnlyThreads = [], runner) {
|
|
20
19
|
const instructions = [];
|
|
20
|
+
const hasNonConflictHints = threads.length > 0 ||
|
|
21
|
+
checks.length > 0 ||
|
|
22
|
+
changesRequestedReviews.length > 0 ||
|
|
23
|
+
actionableComments.length > 0;
|
|
24
|
+
// Leading decision or mandatory instruction depending on what actionable items exist.
|
|
25
|
+
if (hasNonConflictHints) {
|
|
26
|
+
const actionableSections = [];
|
|
27
|
+
if (threads.length > 0)
|
|
28
|
+
actionableSections.push("`## Review threads`");
|
|
29
|
+
if (actionableComments.length > 0)
|
|
30
|
+
actionableSections.push("`## Actionable comments`");
|
|
31
|
+
if (checks.length > 0)
|
|
32
|
+
actionableSections.push("`## Failing checks`");
|
|
33
|
+
if (changesRequestedReviews.length > 0)
|
|
34
|
+
actionableSections.push("`## Changes-requested reviews`");
|
|
35
|
+
const sectionRef = actionableSections.length > 0 ? `under ${actionableSections.join(", ")}` : "above";
|
|
36
|
+
const resolveClause = resolveCommand.hasMutations ? ", then run the `resolve:` command" : "";
|
|
37
|
+
if (hasConflicts) {
|
|
38
|
+
// Conflicts make push mandatory regardless of whether code edits are needed.
|
|
39
|
+
instructions.push(`The branch has merge conflicts that require rebase before merging. Apply any code edits for items ${sectionRef}, commit if edits were made, rebase onto \`origin/${baseBranch}\` per your repository's conventions, push${resolveClause}.`);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
const skipClause = resolveCommand.hasMutations
|
|
43
|
+
? "skip cancellation/commit/push and run the `resolve:` command"
|
|
44
|
+
: "no push is needed";
|
|
45
|
+
instructions.push(`Decide for each item ${sectionRef} whether a code change is warranted. **If any code changes are needed:** cancel in-progress runs first, apply edits, commit, rebase, push${resolveClause}. **If no code changes are needed:** ${skipClause}.`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
else if (hasConflicts) {
|
|
49
|
+
instructions.push(`The branch has merge conflicts — rebase onto \`origin/${baseBranch}\` per your repository's conventions to resolve them, then push.`);
|
|
50
|
+
}
|
|
21
51
|
if (inProgressRunIds.length > 0) {
|
|
22
|
-
instructions.push(`
|
|
52
|
+
instructions.push(`If you decide to push new commits: cancel each in-progress run listed under \`## In-progress runs\` before applying code fixes (e.g. \`gh run cancel <id>\`). Runs may complete between the tick and your action; treat cancellation errors on already-finished runs as non-fatal. Skip this step if you are only resolving threads without pushing — the existing runs remain relevant.`);
|
|
23
53
|
}
|
|
24
54
|
const hasSuggestions = threads.some((t) => t.suggestion);
|
|
25
55
|
if (hasSuggestions) {
|
|
@@ -35,25 +65,25 @@ export function buildFixInstructions(threads, actionableComments, checks, review
|
|
|
35
65
|
instructions.push(`Resolve the threads under \`## Review threads to resolve\` with the \`resolve:\` command shown below. These threads are already outdated or minimized, so no code edit is required for them unless their body reveals separate work you choose to do.`);
|
|
36
66
|
}
|
|
37
67
|
instructions.push(...buildFailingCheckInstructions(checks));
|
|
38
|
-
if (
|
|
68
|
+
if (changesRequestedReviews.length > 0) {
|
|
39
69
|
instructions.push(`For each bullet under \`## Changes-requested reviews\` above: read the review body and apply the requested changes.`);
|
|
40
70
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if (hasCodeChanges) {
|
|
44
|
-
instructions.push(`Commit changed files: \`git add <files> && git commit -m "<descriptive message>"\``);
|
|
45
|
-
instructions.push(`Keep the PR title and description current: if the changes alter the PR's scope or intent, run \`gh pr edit ${prNumber} --title "<new title>" --body "<new body>"\` to reflect them. Skip if the existing title/body still accurately describe the PR.`);
|
|
71
|
+
if (hasNonConflictHints) {
|
|
72
|
+
instructions.push(`If you applied code edits: commit them with a descriptive message, then rebase onto \`origin/${baseBranch}\` per your repository's conventions before pushing.`);
|
|
46
73
|
}
|
|
47
|
-
if (
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if (hasConflicts) {
|
|
52
|
-
instructions.push(`Rebase with conflict resolution: run \`git fetch origin && git rebase origin/${baseBranch}\`. If the rebase halts with conflicts, edit the conflicted files to resolve them, \`git add <files>\`, then \`git rebase --continue\`. Repeat until the rebase completes, then \`git push --force-with-lease\`${captureHint}.`);
|
|
74
|
+
if (resolveCommand.hasMutations) {
|
|
75
|
+
const substituteParts = [];
|
|
76
|
+
if (resolveCommand.requiresHeadSha) {
|
|
77
|
+
substituteParts.push(`\`$HEAD_SHA\` with the pushed commit SHA (or \`$(git rev-parse HEAD)\` if you did not push)`);
|
|
53
78
|
}
|
|
54
|
-
|
|
55
|
-
|
|
79
|
+
if (resolveCommand.requiresDismissMessage) {
|
|
80
|
+
substituteParts.push(`\`$DISMISS_MESSAGE\` with a one-sentence description of what you changed`);
|
|
56
81
|
}
|
|
82
|
+
const substituteHint = substituteParts.length > 0 ? `, substituting ${substituteParts.join(" and ")}` : "";
|
|
83
|
+
instructions.push(`Run the \`resolve:\` command shown above${substituteHint}.`);
|
|
84
|
+
}
|
|
85
|
+
if (cancelledCount > 0) {
|
|
86
|
+
instructions.push(`Do not re-run \`gh run cancel\` on the IDs listed under \`## Cancelled runs\` — those runs were already cancelled by the CLI before this turn.`);
|
|
57
87
|
}
|
|
58
88
|
const firstLookTotal = firstLookThreads.length + firstLookComments.length;
|
|
59
89
|
if (firstLookTotal > 0) {
|
|
@@ -68,28 +98,9 @@ export function buildFixInstructions(threads, actionableComments, checks, review
|
|
|
68
98
|
if (editedTotal > 0) {
|
|
69
99
|
instructions.push(`Items under \`## Review summaries (edited since first look)\` and any first-look bullet tagged \`, edited\` were updated by their author after you previously acknowledged them. Read the updated body before deciding whether any matching \`## Review threads to resolve\` item should be resolved.`);
|
|
70
100
|
}
|
|
71
|
-
if (resolveCommand.hasMutations) {
|
|
72
|
-
const substituteParts = [];
|
|
73
|
-
if (resolveCommand.requiresHeadSha) {
|
|
74
|
-
substituteParts.push(`"$HEAD_SHA" with the pushed commit SHA`);
|
|
75
|
-
}
|
|
76
|
-
if (resolveCommand.requiresDismissMessage) {
|
|
77
|
-
substituteParts.push(`$DISMISS_MESSAGE with a one-sentence description of what you changed`);
|
|
78
|
-
}
|
|
79
|
-
const substituteHint = substituteParts.length > 0 ? `, substituting ${substituteParts.join(" and ")}` : "";
|
|
80
|
-
instructions.push(`Run the \`resolve:\` command shown above${substituteHint}.`);
|
|
81
|
-
}
|
|
82
|
-
if (needsPush && cancelledCount > 0) {
|
|
83
|
-
instructions.push(`Do not re-run \`gh run cancel\` on the IDs listed under \`## Cancelled runs\` — the CLI cancelled those runs before your push, and your push has already triggered new runs with different IDs.`);
|
|
84
|
-
}
|
|
85
101
|
if (resolveCommand.hasMutations) {
|
|
86
102
|
instructions.push(buildShepherdJournalInstruction(prNumber, SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEM_HEADINGS));
|
|
87
103
|
}
|
|
88
|
-
|
|
89
|
-
instructions.push(FIX_INSTRUCTION_STOP_AFTER_PUSH);
|
|
90
|
-
}
|
|
91
|
-
else {
|
|
92
|
-
instructions.push(FIX_INSTRUCTION_STOP_BEFORE_NEXT_TICK);
|
|
93
|
-
}
|
|
104
|
+
instructions.push(FIX_INSTRUCTION_STOP);
|
|
94
105
|
return instructions;
|
|
95
106
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { runIterate } from "./iterate/index.mjs";
|
|
2
|
+
function sleep(ms) {
|
|
3
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
4
|
+
}
|
|
5
|
+
function writeTickProgress(tick, elapsedSeconds, sleepSeconds, verbose) {
|
|
6
|
+
if (process.stderr.isTTY || verbose) {
|
|
7
|
+
process.stderr.write(`[poll tick ${tick} / +${elapsedSeconds}s] WAIT — sleeping ${sleepSeconds}s\n`);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
const MAX_TIMER_MS = 2 ** 31 - 1;
|
|
11
|
+
export async function runPoll(opts) {
|
|
12
|
+
const { intervalSeconds, timeoutSeconds, ...iterateOpts } = opts;
|
|
13
|
+
const intervalMs = Math.min(intervalSeconds * 1000, MAX_TIMER_MS);
|
|
14
|
+
const timeoutMs = Math.min(timeoutSeconds * 1000, MAX_TIMER_MS);
|
|
15
|
+
const start = Date.now();
|
|
16
|
+
let tick = 0;
|
|
17
|
+
let lastResult;
|
|
18
|
+
const verbose = opts.verbose === true;
|
|
19
|
+
while (true) {
|
|
20
|
+
tick += 1;
|
|
21
|
+
lastResult = await runIterate(iterateOpts);
|
|
22
|
+
if (lastResult.action !== "wait")
|
|
23
|
+
return lastResult;
|
|
24
|
+
const elapsedMs = Date.now() - start;
|
|
25
|
+
const remainingMs = timeoutMs - elapsedMs;
|
|
26
|
+
if (remainingMs <= 0)
|
|
27
|
+
return lastResult;
|
|
28
|
+
const nextSleepMs = Math.min(intervalMs, remainingMs);
|
|
29
|
+
writeTickProgress(tick, Math.round(elapsedMs / 1000), Math.round(nextSleepMs / 1000), verbose);
|
|
30
|
+
await sleep(nextSleepMs);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
vi.mock("./iterate/index.mts", () => ({ runIterate: vi.fn() }));
|
|
3
|
+
import { runIterate } from "./iterate/index.mjs";
|
|
4
|
+
const mockRunIterate = vi.mocked(runIterate);
|
|
5
|
+
function makeWaitResult(overrides = {}) {
|
|
6
|
+
return {
|
|
7
|
+
action: "wait",
|
|
8
|
+
pr: 42,
|
|
9
|
+
repo: "owner/repo",
|
|
10
|
+
status: "IN_PROGRESS",
|
|
11
|
+
state: "OPEN",
|
|
12
|
+
mergeStateStatus: "BLOCKED",
|
|
13
|
+
mergeStatus: "BLOCKED",
|
|
14
|
+
reviewDecision: "REVIEW_REQUIRED",
|
|
15
|
+
blockingBotReviewInProgress: false,
|
|
16
|
+
isDraft: false,
|
|
17
|
+
shouldCancel: false,
|
|
18
|
+
remainingSeconds: 0,
|
|
19
|
+
summary: { passing: 2, failing: 0, inProgress: 1, skipped: 0, filtered: 0 },
|
|
20
|
+
baseBranch: "main",
|
|
21
|
+
checks: [],
|
|
22
|
+
log: "WAIT: 2 passing, 1 in-progress",
|
|
23
|
+
...overrides,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function makeCancelResult() {
|
|
27
|
+
return {
|
|
28
|
+
action: "cancel",
|
|
29
|
+
pr: 42,
|
|
30
|
+
repo: "owner/repo",
|
|
31
|
+
status: "READY",
|
|
32
|
+
state: "MERGED",
|
|
33
|
+
mergeStateStatus: "CLEAN",
|
|
34
|
+
mergeStatus: "CLEAN",
|
|
35
|
+
reviewDecision: "APPROVED",
|
|
36
|
+
blockingBotReviewInProgress: false,
|
|
37
|
+
isDraft: false,
|
|
38
|
+
shouldCancel: true,
|
|
39
|
+
remainingSeconds: 0,
|
|
40
|
+
summary: { passing: 3, failing: 0, inProgress: 0, skipped: 0, filtered: 0 },
|
|
41
|
+
baseBranch: "main",
|
|
42
|
+
checks: [],
|
|
43
|
+
reason: "merged",
|
|
44
|
+
log: "CANCEL: PR #42 is merged — stopping",
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function makeMarkReadyResult() {
|
|
48
|
+
return {
|
|
49
|
+
action: "mark_ready",
|
|
50
|
+
pr: 42,
|
|
51
|
+
repo: "owner/repo",
|
|
52
|
+
status: "READY",
|
|
53
|
+
state: "OPEN",
|
|
54
|
+
mergeStateStatus: "CLEAN",
|
|
55
|
+
mergeStatus: "CLEAN",
|
|
56
|
+
reviewDecision: "APPROVED",
|
|
57
|
+
blockingBotReviewInProgress: false,
|
|
58
|
+
isDraft: false,
|
|
59
|
+
shouldCancel: false,
|
|
60
|
+
remainingSeconds: 0,
|
|
61
|
+
summary: { passing: 3, failing: 0, inProgress: 0, skipped: 0, filtered: 0 },
|
|
62
|
+
baseBranch: "main",
|
|
63
|
+
checks: [],
|
|
64
|
+
markedReady: true,
|
|
65
|
+
log: "MARKED READY: PR #42 converted from draft to ready for review",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function registerPollHooks() {
|
|
69
|
+
beforeEach(() => {
|
|
70
|
+
vi.clearAllMocks();
|
|
71
|
+
vi.useFakeTimers();
|
|
72
|
+
});
|
|
73
|
+
afterEach(() => {
|
|
74
|
+
vi.useRealTimers();
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
export { mockRunIterate, makeWaitResult, makeCancelResult, makeMarkReadyResult, registerPollHooks };
|
|
@@ -37,16 +37,13 @@ export function buildFetchInstructions(prNumber, result, runner) {
|
|
|
37
37
|
}
|
|
38
38
|
if (hasCodeItems) {
|
|
39
39
|
instructions.push(`Read and edit each file referenced under \`## Actionable Review Threads\`, \`## Actionable PR Comments\`, and \`## Pending CHANGES_REQUESTED reviews\` above. Reclassify each fixed item as Fixed. If an item is too complex to address, leave it as Actionable for the final report.`);
|
|
40
|
-
instructions.push(`
|
|
41
|
-
instructions.push(`Keep the PR title and description current: if the fixes alter the PR's scope or intent, run \`gh pr edit ${prNumber} --title "<new title>" --body "<new body>"\` to reflect them. Skip if the existing title/body still accurately describe the PR.`);
|
|
42
|
-
instructions.push(`Rebase and push: \`BASE_BRANCH=$(gh pr view ${prNumber} --json baseRefName --jq .baseRefName) && git fetch origin && git rebase "origin/$BASE_BRANCH" && git push --force-with-lease\`.`);
|
|
43
|
-
instructions.push(`Cancel stale in-progress runs: \`BRANCH=$(git rev-parse --abbrev-ref HEAD) && CURRENT_SHA=$(git rev-parse HEAD) && gh run list --branch "$BRANCH" --status in_progress --json databaseId,headSha --jq ".[] | select(.headSha != \\"$CURRENT_SHA\\") | .databaseId" | xargs -I{} gh run cancel {}\`.`);
|
|
40
|
+
instructions.push(`If you applied code edits: commit them with a descriptive message, cancel any stale in-progress runs, then rebase and push per your repository's conventions.`);
|
|
44
41
|
}
|
|
45
42
|
if (resolutionOnlyThreads.length > 0) {
|
|
46
43
|
instructions.push(`Resolve each thread under \`## Review threads to resolve\` with \`--resolve-thread-ids\`. These threads are already outdated or minimized, so no code edit is required for them unless their body reveals separate work you choose to do.`);
|
|
47
44
|
}
|
|
48
45
|
const requireShaHint = hasCodeItems
|
|
49
|
-
? ` Include \`--require-sha $(git rev-parse HEAD)\` only when
|
|
46
|
+
? ` Include \`--require-sha $(git rev-parse HEAD)\` only when you pushed new commits.`
|
|
50
47
|
: "";
|
|
51
48
|
const dismissNote = changesRequestedReviews.length > 0
|
|
52
49
|
? ` For \`--dismiss-review-ids\`: \`--message\` is required with one specific sentence describing the fix or the reason for not acting (no boilerplate like "address review comments"); omit \`--message\` when not dismissing. Review-summary IDs (\`PRR_…\` from \`## Review summaries\`) go into \`--minimize-comment-ids\`, never \`--dismiss-review-ids\`.`
|