pr-shepherd 0.22.0 → 0.23.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 +86 -125
- package/bin/cli/args.mjs +2 -0
- package/bin/cli/fix-formatter.mjs +2 -1
- package/bin/cli/formatters.mjs +3 -45
- package/bin/cli/handlers.mjs +22 -1
- package/bin/cli/help-command-pages.mjs +27 -2
- package/bin/cli/help-top-page.mjs +2 -0
- package/bin/cli/list-formatters.mjs +3 -1
- package/bin/cli/mark-files-as-viewed-flags.mjs +34 -0
- package/bin/cli/mark-files-as-viewed-formatter.mjs +52 -0
- package/bin/cli/mutate-formatter.mjs +50 -0
- package/bin/cli-parser.mjs +11 -2
- package/bin/cli-parser.test-support.mjs +6 -1
- package/bin/commands/check.mjs +20 -55
- package/bin/commands/commit-suggestion-instruction.mjs +1 -1
- package/bin/commands/iterate/classify.mjs +25 -30
- package/bin/commands/iterate/fix-code.mjs +21 -17
- package/bin/commands/iterate/render.mjs +3 -3
- package/bin/commands/iterate-thread-test-support.mjs +18 -0
- package/bin/commands/iterate.fix-code-in-progress.test-support.mjs +7 -3
- package/bin/commands/mark-files-as-viewed.mjs +220 -0
- package/bin/commands/resolve-mutate.mjs +41 -4
- package/bin/commands/resolve.mjs +15 -76
- package/bin/commands/resolve.test-support.mjs +2 -0
- package/bin/commands/shepherd-journal.mjs +1 -1
- package/bin/comments/authors.mjs +14 -0
- package/bin/comments/minimize-policy.mjs +6 -3
- package/bin/comments/pending-ops.mjs +6 -0
- package/bin/comments/resolve.mjs +28 -11
- package/bin/comments/resolve.test-support.mjs +6 -1
- package/bin/comments/review-visibility.mjs +14 -0
- package/bin/comments/thread-visibility.mjs +60 -0
- package/bin/comments/visible-comments.mjs +1 -1
- package/bin/github/batch-parser-helpers.mjs +3 -4
- package/bin/github/batch-parsers.mjs +6 -6
- package/bin/reporters/agent.mjs +1 -0
- package/bin/threads/transcript.mjs +6 -6
- package/package.json +1 -1
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
- package/plugins/pr-shepherd/skills/mark-files-as-viewed/SKILL.md +31 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
function pushIds(lines, label, ids) {
|
|
2
|
+
if (ids?.length)
|
|
3
|
+
lines.push(`${label} (${ids.length}): ${ids.join(", ")}`);
|
|
4
|
+
}
|
|
5
|
+
function formatRateLimit(result) {
|
|
6
|
+
const rateLimit = result.rateLimit;
|
|
7
|
+
if (rateLimit) {
|
|
8
|
+
const details = [
|
|
9
|
+
typeof rateLimit.retryAfterSeconds === "number"
|
|
10
|
+
? `retry after ${rateLimit.retryAfterSeconds}s`
|
|
11
|
+
: null,
|
|
12
|
+
typeof rateLimit.remaining === "number" && typeof rateLimit.limit === "number"
|
|
13
|
+
? `remaining ${rateLimit.remaining}/${rateLimit.limit}`
|
|
14
|
+
: null,
|
|
15
|
+
typeof rateLimit.resetAt === "number"
|
|
16
|
+
? `reset at ${new Date(rateLimit.resetAt * 1000).toISOString()}`
|
|
17
|
+
: null,
|
|
18
|
+
]
|
|
19
|
+
.filter(Boolean)
|
|
20
|
+
.join(", ");
|
|
21
|
+
const detailSuffix = details ? ` (${details})` : "";
|
|
22
|
+
return `Stopped: GitHub rate limit hit — ${rateLimit.message}${detailSuffix}`;
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
export function formatMutateResult(result) {
|
|
27
|
+
const lines = [];
|
|
28
|
+
pushIds(lines, "Replied to threads", result.repliedThreads);
|
|
29
|
+
pushIds(lines, "Resolved threads", result.resolvedThreads);
|
|
30
|
+
pushIds(lines, "Minimized comments", result.minimizedComments);
|
|
31
|
+
pushIds(lines, "Dismissed reviews", result.dismissedReviews);
|
|
32
|
+
pushIds(lines, "Skipped dismissals", result.skippedDismissals);
|
|
33
|
+
pushIds(lines, "Skipped human thread resolves", result.skippedHumanResolves);
|
|
34
|
+
pushIds(lines, "Skipped human minimizes", result.skippedHumanMinimizes);
|
|
35
|
+
pushIds(lines, "Skipped human review dismissals", result.skippedHumanDismissals);
|
|
36
|
+
pushIds(lines, "Skipped non-human/unknown thread replies", result.skippedNonHumanReplies);
|
|
37
|
+
const rateLimit = formatRateLimit(result);
|
|
38
|
+
if (rateLimit)
|
|
39
|
+
lines.push(rateLimit);
|
|
40
|
+
pushIds(lines, "Not replied due to rate limit", result.unrepliedThreads);
|
|
41
|
+
pushIds(lines, "Not resolved due to rate limit", result.unresolvedThreads);
|
|
42
|
+
pushIds(lines, "Not minimized due to rate limit", result.unminimizedComments);
|
|
43
|
+
pushIds(lines, "Not dismissed due to rate limit", result.undismissedReviews);
|
|
44
|
+
const errors = result.rateLimit
|
|
45
|
+
? result.errors.filter((e) => !e.startsWith("rate limit:"))
|
|
46
|
+
: result.errors;
|
|
47
|
+
if (errors.length)
|
|
48
|
+
lines.push(`Errors:\n ${errors.join("\n ")}`);
|
|
49
|
+
return lines.join("\n");
|
|
50
|
+
}
|
package/bin/cli-parser.mjs
CHANGED
|
@@ -7,9 +7,12 @@
|
|
|
7
7
|
* [--stall-timeout <duration>] [--no-auto-mark-ready]
|
|
8
8
|
* [--no-auto-cancel-actionable]
|
|
9
9
|
* pr-shepherd resolve [PR] [--fetch] [--resolve-thread-ids A,B] [--minimize-comment-ids X,Y]
|
|
10
|
-
* [--
|
|
10
|
+
* [--reply-thread-ids A,B] [--dismiss-review-ids Q]
|
|
11
|
+
* [--message MSG] [--require-sha SHA]
|
|
11
12
|
* pr-shepherd commit-suggestion [PR] --thread-id ID --message MSG [--description DESC]
|
|
12
13
|
* [--format text|json]
|
|
14
|
+
* pr-shepherd mark-files-as-viewed [PR] [files...] [--tests] [--match REGEX]
|
|
15
|
+
* [--format text|json]
|
|
13
16
|
* pr-shepherd iterate [PR] [--format text|json] [--ready-delay Nm]
|
|
14
17
|
* [--stall-timeout <duration>] [--no-auto-mark-ready]
|
|
15
18
|
* [--no-auto-cancel-actionable]
|
|
@@ -25,7 +28,7 @@ import { parseCommonArgs, getFlag, hasFlag, parseList } from "./cli/args.mjs";
|
|
|
25
28
|
import { isDefaultPollInvocation, validateDefaultPollArgs } from "./cli/default-poll.mjs";
|
|
26
29
|
import { USAGE, maybePrintHelp } from "./cli/help.mjs";
|
|
27
30
|
import { formatFetchResult, formatMutateResult } from "./cli/formatters.mjs";
|
|
28
|
-
import { handleClean, handleCommitSuggestion, handleIterate } from "./cli/handlers.mjs";
|
|
31
|
+
import { handleClean, handleCommitSuggestion, handleIterate, handleMarkFilesAsViewed, } from "./cli/handlers.mjs";
|
|
29
32
|
import { handlePoll } from "./cli/poll-handler.mjs";
|
|
30
33
|
import { setupLog } from "./log/setup.mjs";
|
|
31
34
|
// ---------------------------------------------------------------------------
|
|
@@ -75,6 +78,9 @@ export async function main(argv) {
|
|
|
75
78
|
case "commit-suggestion":
|
|
76
79
|
await handleCommitSuggestion(args.slice(1));
|
|
77
80
|
break;
|
|
81
|
+
case "mark-files-as-viewed":
|
|
82
|
+
await handleMarkFilesAsViewed(args.slice(1));
|
|
83
|
+
break;
|
|
78
84
|
case "iterate":
|
|
79
85
|
await handleIterate(args.slice(1));
|
|
80
86
|
break;
|
|
@@ -119,12 +125,14 @@ async function handleLogFile(args) {
|
|
|
119
125
|
async function handleResolve(args) {
|
|
120
126
|
const { prNumber, global: globalOpts, extra } = parseCommonArgs(args);
|
|
121
127
|
const resolveThreadIds = parseList(getFlag(extra, "--resolve-thread-ids"));
|
|
128
|
+
const replyThreadIds = parseList(getFlag(extra, "--reply-thread-ids"));
|
|
122
129
|
const minimizeCommentIds = parseList(getFlag(extra, "--minimize-comment-ids"));
|
|
123
130
|
const dismissReviewIds = parseList(getFlag(extra, "--dismiss-review-ids"));
|
|
124
131
|
const dismissMessage = getFlag(extra, "--message") ?? undefined;
|
|
125
132
|
const requireSha = getFlag(extra, "--require-sha") ?? undefined;
|
|
126
133
|
const fetchMode = hasFlag(extra, "--fetch") ||
|
|
127
134
|
(resolveThreadIds.length === 0 &&
|
|
135
|
+
replyThreadIds.length === 0 &&
|
|
128
136
|
minimizeCommentIds.length === 0 &&
|
|
129
137
|
dismissReviewIds.length === 0);
|
|
130
138
|
if (fetchMode) {
|
|
@@ -138,6 +146,7 @@ async function handleResolve(args) {
|
|
|
138
146
|
...globalOpts,
|
|
139
147
|
prNumber,
|
|
140
148
|
resolveThreadIds,
|
|
149
|
+
replyThreadIds,
|
|
141
150
|
minimizeCommentIds,
|
|
142
151
|
dismissReviewIds,
|
|
143
152
|
dismissMessage,
|
|
@@ -10,6 +10,9 @@ vi.mock("./commands/log-file.mts", () => ({
|
|
|
10
10
|
vi.mock("./commands/commit-suggestion.mts", () => ({
|
|
11
11
|
runCommitSuggestion: vi.fn(),
|
|
12
12
|
}));
|
|
13
|
+
vi.mock("./commands/mark-files-as-viewed.mts", () => ({
|
|
14
|
+
runMarkFilesAsViewed: vi.fn(),
|
|
15
|
+
}));
|
|
13
16
|
vi.mock("./commands/iterate/index.mts", async (importOriginal) => {
|
|
14
17
|
const actual = await importOriginal();
|
|
15
18
|
return { ...actual, runIterate: vi.fn() };
|
|
@@ -17,9 +20,11 @@ vi.mock("./commands/iterate/index.mts", async (importOriginal) => {
|
|
|
17
20
|
import { main } from "./cli-parser.mjs";
|
|
18
21
|
import { runLogFile } from "./commands/log-file.mjs";
|
|
19
22
|
import { runResolveFetch, runResolveMutate } from "./commands/resolve.mjs";
|
|
23
|
+
import { runMarkFilesAsViewed } from "./commands/mark-files-as-viewed.mjs";
|
|
20
24
|
const mockRunResolveFetch = vi.mocked(runResolveFetch);
|
|
21
25
|
const mockRunResolveMutate = vi.mocked(runResolveMutate);
|
|
22
26
|
const mockRunLogFile = vi.mocked(runLogFile);
|
|
27
|
+
const mockRunMarkFilesAsViewed = vi.mocked(runMarkFilesAsViewed);
|
|
23
28
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
24
29
|
let stdoutSpy;
|
|
25
30
|
let stderrSpy;
|
|
@@ -39,4 +44,4 @@ export function registerHooks() {
|
|
|
39
44
|
stderrSpy.mockRestore();
|
|
40
45
|
});
|
|
41
46
|
}
|
|
42
|
-
export { getStdout, main, mockRunLogFile, mockRunResolveFetch, mockRunResolveMutate, readFileSync, runLogFile, runResolveFetch, runResolveMutate, stderrSpy, stdoutSpy, };
|
|
47
|
+
export { getStdout, main, mockRunLogFile, mockRunMarkFilesAsViewed, mockRunResolveFetch, mockRunResolveMutate, readFileSync, runLogFile, runResolveFetch, runResolveMutate, stderrSpy, stdoutSpy, };
|
package/bin/commands/check.mjs
CHANGED
|
@@ -3,8 +3,6 @@ import { getRepoInfo, getCurrentPrNumber } from "../github/client.mjs";
|
|
|
3
3
|
import { classifyChecks, getCiVerdict } from "../checks/classify.mjs";
|
|
4
4
|
import { mergeStartupFailureChecks } from "../checks/startup-failures.mjs";
|
|
5
5
|
import { fetchStartupFailureChecks, triageFailingChecks } from "../checks/triage.mjs";
|
|
6
|
-
import { getOutdatedThreads } from "../comments/outdated.mjs";
|
|
7
|
-
import { autoResolveOutdated } from "../comments/resolve.mjs";
|
|
8
6
|
import { deriveMergeStatus } from "../merge-status/derive.mjs";
|
|
9
7
|
import { loadConfig } from "../config/load.mjs";
|
|
10
8
|
import { classifyVisibleComments } from "../comments/visible-comments.mjs";
|
|
@@ -14,6 +12,8 @@ import { buildTerminalReport } from "./check-terminal-report.mjs";
|
|
|
14
12
|
import { isBlockedByFilteredCheck, refreshReadyMergeability, refreshUnknownMergeability, } from "./ready-mergeability.mjs";
|
|
15
13
|
import { loadSeenMap, markSeen, classifyItem } from "../state/seen-comments.mjs";
|
|
16
14
|
import { threadTranscriptBody } from "../threads/transcript.mjs";
|
|
15
|
+
import { classifyThreadVisibility } from "../comments/thread-visibility.mjs";
|
|
16
|
+
import { classifyReviewsForDisplay } from "../comments/review-visibility.mjs";
|
|
17
17
|
export async function runCheck(opts) {
|
|
18
18
|
const repo = await getRepoInfo();
|
|
19
19
|
const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
|
|
@@ -44,50 +44,9 @@ export async function runCheck(opts) {
|
|
|
44
44
|
const stateKey = { owner: repo.owner, repo: repo.name, pr: prNumber };
|
|
45
45
|
const seenMap = await loadSeenMap(stateKey);
|
|
46
46
|
const triaged = await attachUnseenCheckAnnotations(triagedBase, seenMap, prNumber);
|
|
47
|
-
const unresolvedThreads = batchData.reviewThreads.filter((t) => !t.isResolved);
|
|
48
|
-
const outdated = getOutdatedThreads(unresolvedThreads);
|
|
49
|
-
let autoResolved = [];
|
|
50
|
-
let autoResolveErrors = [];
|
|
51
|
-
if (opts.autoResolve && outdated.length > 0) {
|
|
52
|
-
const { resolved: resolvedIds, errors } = await autoResolveOutdated(outdated.map((t) => t.id));
|
|
53
|
-
const resolvedIdsSet = new Set(resolvedIds); // ⚡ Bolt optimization: O(1) lookup
|
|
54
|
-
autoResolved = outdated.filter((t) => resolvedIdsSet.has(t.id));
|
|
55
|
-
autoResolveErrors = errors;
|
|
56
|
-
}
|
|
57
|
-
const activeThreads = unresolvedThreads.filter((t) => !t.isOutdated && !t.isMinimized);
|
|
58
|
-
const outdatedCandidates = batchData.reviewThreads.filter((t) => t.isOutdated);
|
|
59
|
-
const resolvedCandidates = batchData.reviewThreads.filter((t) => t.isResolved && !t.isOutdated);
|
|
60
|
-
const minimizedThreadCandidates = batchData.reviewThreads.filter((t) => t.isMinimized && !t.isResolved && !t.isOutdated);
|
|
61
47
|
const minimizedCommentCandidates = batchData.comments.filter((c) => c.isMinimized);
|
|
62
48
|
const visibleCommentClassification = classifyVisibleComments(batchData.comments, seenMap, config.iterate.minimizeComments);
|
|
63
|
-
const
|
|
64
|
-
const firstLookThreads = [
|
|
65
|
-
...outdatedCandidates.flatMap((t) => {
|
|
66
|
-
const cls = classifyItem(t.id, threadTranscriptBody(t), seenMap);
|
|
67
|
-
if (cls === "unchanged")
|
|
68
|
-
return [];
|
|
69
|
-
const base = {
|
|
70
|
-
...t,
|
|
71
|
-
firstLookStatus: "outdated",
|
|
72
|
-
autoResolved: autoResolvedIds.has(t.id),
|
|
73
|
-
};
|
|
74
|
-
return cls === "edited" ? [{ ...base, edited: true }] : [base];
|
|
75
|
-
}),
|
|
76
|
-
...resolvedCandidates.flatMap((t) => {
|
|
77
|
-
const cls = classifyItem(t.id, threadTranscriptBody(t), seenMap);
|
|
78
|
-
if (cls === "unchanged")
|
|
79
|
-
return [];
|
|
80
|
-
const base = { ...t, firstLookStatus: "resolved" };
|
|
81
|
-
return cls === "edited" ? [{ ...base, edited: true }] : [base];
|
|
82
|
-
}),
|
|
83
|
-
...minimizedThreadCandidates.flatMap((t) => {
|
|
84
|
-
const cls = classifyItem(t.id, threadTranscriptBody(t), seenMap);
|
|
85
|
-
if (cls === "unchanged")
|
|
86
|
-
return [];
|
|
87
|
-
const base = { ...t, firstLookStatus: "minimized" };
|
|
88
|
-
return cls === "edited" ? [{ ...base, edited: true }] : [base];
|
|
89
|
-
}),
|
|
90
|
-
];
|
|
49
|
+
const threadVisibility = classifyThreadVisibility(batchData.reviewThreads, seenMap);
|
|
91
50
|
const firstLookComments = minimizedCommentCandidates.flatMap((c) => {
|
|
92
51
|
const cls = classifyItem(c.id, c.body, seenMap);
|
|
93
52
|
if (cls === "unchanged")
|
|
@@ -107,16 +66,22 @@ export async function runCheck(opts) {
|
|
|
107
66
|
else
|
|
108
67
|
seenSummaries.push(r);
|
|
109
68
|
}
|
|
69
|
+
const changesRequestedReviewVisibility = classifyReviewsForDisplay(batchData.changesRequestedReviews, seenMap);
|
|
70
|
+
const approvedReviewVisibility = classifyReviewsForDisplay(batchData.approvedReviews, seenMap);
|
|
110
71
|
await Promise.allSettled([
|
|
111
|
-
...firstLookThreads.map((t) => markSeen(stateKey, t.id, threadTranscriptBody(t))),
|
|
112
72
|
...firstLookComments.map((c) => markSeen(stateKey, c.id, c.body)),
|
|
73
|
+
...threadVisibility.toMarkSeen.map((t) => markSeen(stateKey, t.id, threadTranscriptBody(t))),
|
|
113
74
|
...visibleCommentClassification.toMarkSeen.map((c) => markSeen(stateKey, c.id, c.body)),
|
|
114
75
|
...[...firstLookSummaries, ...editedSummaries].map((r) => markSeen(stateKey, r.id, r.body)),
|
|
76
|
+
...changesRequestedReviewVisibility.toMarkSeen.map((r) => markSeen(stateKey, r.id, r.body)),
|
|
77
|
+
...approvedReviewVisibility.toMarkSeen.map((r) => markSeen(stateKey, r.id, r.body)),
|
|
115
78
|
]);
|
|
116
|
-
const
|
|
117
|
-
|
|
79
|
+
const changesRequestedReviews = changesRequestedReviewVisibility.visible;
|
|
80
|
+
const changesRequestedReviewCount = batchData.changesRequestedReviews.length;
|
|
81
|
+
const approvedReviews = approvedReviewVisibility.visible;
|
|
82
|
+
let status = computeStatus(verdict, threadVisibility.activeThreads.length + threadVisibility.resolutionOnlyThreads.length, visibleCommentClassification.actionable.length, mergeStatus, changesRequestedReviewCount);
|
|
118
83
|
if (status === "READY" && !didRefreshMergeability) {
|
|
119
|
-
const refreshed = await refreshReadyMergeability(prNumber, repo, batchData, verdict, activeThreads.length + resolutionOnlyThreads.length, visibleCommentClassification.actionable.length);
|
|
84
|
+
const refreshed = await refreshReadyMergeability(prNumber, repo, batchData, verdict, threadVisibility.activeThreads.length + threadVisibility.resolutionOnlyThreads.length, visibleCommentClassification.actionable.length);
|
|
120
85
|
batchData = refreshed.batchData;
|
|
121
86
|
mergeStatus = refreshed.mergeStatus;
|
|
122
87
|
status = refreshed.status;
|
|
@@ -139,22 +104,22 @@ export async function runCheck(opts) {
|
|
|
139
104
|
blockedByFilteredCheck,
|
|
140
105
|
},
|
|
141
106
|
threads: {
|
|
142
|
-
actionable: activeThreads,
|
|
143
|
-
resolutionOnly: resolutionOnlyThreads,
|
|
144
|
-
autoResolved,
|
|
145
|
-
autoResolveErrors,
|
|
146
|
-
firstLook: firstLookThreads,
|
|
107
|
+
actionable: threadVisibility.activeThreads,
|
|
108
|
+
resolutionOnly: threadVisibility.resolutionOnlyThreads,
|
|
109
|
+
autoResolved: [],
|
|
110
|
+
autoResolveErrors: [],
|
|
111
|
+
firstLook: threadVisibility.firstLookThreads,
|
|
147
112
|
},
|
|
148
113
|
comments: {
|
|
149
114
|
actionable: visibleCommentClassification.actionable,
|
|
150
115
|
minimizeIds: visibleCommentClassification.minimizeIds,
|
|
151
116
|
firstLook: firstLookComments,
|
|
152
117
|
},
|
|
153
|
-
changesRequestedReviews
|
|
118
|
+
changesRequestedReviews,
|
|
154
119
|
reviewSummaries: seenSummaries,
|
|
155
120
|
firstLookSummaries,
|
|
156
121
|
editedSummaries,
|
|
157
|
-
approvedReviews
|
|
122
|
+
approvedReviews,
|
|
158
123
|
branchProtection: batchData.branchProtection,
|
|
159
124
|
};
|
|
160
125
|
}
|
|
@@ -19,5 +19,5 @@ export function buildCommitSuggestionInstruction(prNumber, sectionName, includeD
|
|
|
19
19
|
const driftHint = includeDriftHint
|
|
20
20
|
? " If the patch fails to apply (drift since the suggestion was written), fall through to the manual fix step."
|
|
21
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.
|
|
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. Human-authored thread IDs are replied to by the resolve command below; Shepherd does not auto-resolve them.${driftHint} Do not retry the same command.`;
|
|
23
23
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { buildPrShepherdCommand } from "../../cli/runner.mjs";
|
|
2
2
|
import { shouldMinimizeAuthor } from "../../comments/minimize-policy.mjs";
|
|
3
|
+
import { isHumanAuthor } from "../../comments/authors.mjs";
|
|
3
4
|
function dedupeIds(ids) {
|
|
4
5
|
const seen = new Set();
|
|
5
6
|
const out = [];
|
|
@@ -16,18 +17,21 @@ export function classifyReviewSummaries(summaries, approvals, minimizeApprovals,
|
|
|
16
17
|
// they are already minimized server-side (body changed after minimize was applied).
|
|
17
18
|
// First-look bodies are rendered so the agent sees them before the minimize happens.
|
|
18
19
|
const minimizeIds = [...summaries.firstLook, ...summaries.seen]
|
|
19
|
-
.filter((r) => shouldMinimizeAuthor(r.authorType, minimizeComments))
|
|
20
|
+
.filter((r) => shouldMinimizeAuthor(r.authorType, minimizeComments, r.author))
|
|
20
21
|
.map((r) => r.id);
|
|
21
22
|
if (minimizeApprovals) {
|
|
23
|
+
const surfacedApprovals = [];
|
|
22
24
|
for (const r of approvals) {
|
|
23
|
-
if (shouldMinimizeAuthor(r.authorType, minimizeComments))
|
|
25
|
+
if (shouldMinimizeAuthor(r.authorType, minimizeComments, r.author))
|
|
24
26
|
minimizeIds.push(r.id);
|
|
27
|
+
else
|
|
28
|
+
surfacedApprovals.push(r);
|
|
25
29
|
}
|
|
26
30
|
return {
|
|
27
31
|
minimizeIds,
|
|
28
32
|
firstLookSummaries: summaries.firstLook,
|
|
29
33
|
editedSummaries: summaries.edited,
|
|
30
|
-
surfacedApprovals
|
|
34
|
+
surfacedApprovals,
|
|
31
35
|
};
|
|
32
36
|
}
|
|
33
37
|
return {
|
|
@@ -37,45 +41,36 @@ export function classifyReviewSummaries(summaries, approvals, minimizeApprovals,
|
|
|
37
41
|
surfacedApprovals: approvals,
|
|
38
42
|
};
|
|
39
43
|
}
|
|
40
|
-
export function buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds,
|
|
44
|
+
export function buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, _reviews, checks, prNumber) {
|
|
41
45
|
const argv = buildPrShepherdCommand(["resolve", String(prNumber)]).argv;
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
+
const allThreads = [...threads, ...resolutionOnlyThreads];
|
|
47
|
+
const replyThreadIds = dedupeIds(allThreads.filter(isHumanAuthor).map((t) => t.id));
|
|
48
|
+
const resolveThreadIds = dedupeIds(allThreads.filter((t) => !isHumanAuthor(t)).map((t) => t.id));
|
|
49
|
+
if (replyThreadIds.length > 0) {
|
|
50
|
+
argv.push("--reply-thread-ids", replyThreadIds.join(","));
|
|
51
|
+
argv.push("--message", "$DISMISS_MESSAGE");
|
|
52
|
+
}
|
|
53
|
+
if (resolveThreadIds.length > 0) {
|
|
54
|
+
argv.push("--resolve-thread-ids", resolveThreadIds.join(","));
|
|
46
55
|
}
|
|
47
56
|
if (allCommentIds.length > 0) {
|
|
48
57
|
argv.push("--minimize-comment-ids", allCommentIds.join(","));
|
|
49
58
|
}
|
|
50
|
-
|
|
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;
|
|
60
|
-
if (hasDismiss) {
|
|
61
|
-
argv.push("--dismiss-review-ids", filteredReviewIds.join(","));
|
|
62
|
-
argv.push("--message", "$DISMISS_MESSAGE");
|
|
63
|
-
}
|
|
64
|
-
// hasMutations = we appended at least one of --resolve-thread-ids,
|
|
65
|
-
// --minimize-comment-ids, or --dismiss-review-ids. Returned explicitly
|
|
59
|
+
// hasMutations = we appended at least one reply, resolve, or minimize mutation. Returned explicitly
|
|
66
60
|
// (rather than derived from argv.length) so callers don't couple to the
|
|
67
61
|
// base-argv shape.
|
|
68
|
-
const hasMutations =
|
|
62
|
+
const hasMutations = replyThreadIds.length > 0 || resolveThreadIds.length > 0 || allCommentIds.length > 0;
|
|
69
63
|
// `requiresHeadSha` is only added when this resolve command includes a
|
|
70
|
-
// mutation that can race with a moving HEAD:
|
|
71
|
-
//
|
|
72
|
-
const hasCodeMutations = hasMutations && (threads.length > 0 || checks.length > 0
|
|
64
|
+
// mutation that can race with a moving HEAD: replying after actionable
|
|
65
|
+
// thread fixes or addressing failing checks.
|
|
66
|
+
const hasCodeMutations = hasMutations && (threads.length > 0 || checks.length > 0);
|
|
73
67
|
const requiresHeadSha = hasCodeMutations;
|
|
74
68
|
return {
|
|
75
69
|
argv,
|
|
76
70
|
requiresHeadSha,
|
|
77
|
-
requiresDismissMessage:
|
|
78
|
-
...(
|
|
71
|
+
requiresDismissMessage: replyThreadIds.length > 0,
|
|
72
|
+
...(replyThreadIds.length > 0 ? { replyThreadIds } : undefined),
|
|
73
|
+
...(resolveThreadIds.length > 0 ? { resolveThreadIds } : undefined),
|
|
79
74
|
hasMutations,
|
|
80
75
|
};
|
|
81
76
|
}
|
|
@@ -1,25 +1,35 @@
|
|
|
1
1
|
/* eslint-disable max-lines */
|
|
2
|
-
import { readFixAttempts, writeFixAttempts } from "../../state/fix-attempts.mjs";
|
|
2
|
+
import { readFixAttempts, writeFixAttempts, } from "../../state/fix-attempts.mjs";
|
|
3
3
|
import { toAgentThread, toAgentComment, toAgentChecks } from "../../reporters/agent.mjs";
|
|
4
|
-
import { markSeen } from "../../state/seen-comments.mjs";
|
|
4
|
+
import { hashBody, markSeen } from "../../state/seen-comments.mjs";
|
|
5
5
|
import { checkEscalateTriggers, validateBaseBranch, buildEscalateSuggestion, buildEscalateHumanMessage, } from "./escalate.mjs";
|
|
6
6
|
import { buildResolveCommand } from "./classify.mjs";
|
|
7
7
|
import { buildFixInstructions } from "./render.mjs";
|
|
8
8
|
import { applyStallGuard } from "./stall.mjs";
|
|
9
9
|
import { tryCancelRun, buildInProgressRunIds } from "./helpers.mjs";
|
|
10
10
|
import { annotationMarkerBody } from "../check-annotations.mjs";
|
|
11
|
+
import { threadTranscriptBody } from "../../threads/transcript.mjs";
|
|
12
|
+
function nextFixAttempts(stored, headSha, threads) {
|
|
13
|
+
const threadAttempts = stored ? { ...stored.threadAttempts } : {};
|
|
14
|
+
const threadBodyHashes = stored?.threadBodyHashes
|
|
15
|
+
? { ...stored.threadBodyHashes }
|
|
16
|
+
: {};
|
|
17
|
+
for (const t of threads) {
|
|
18
|
+
const bodyHash = hashBody(threadTranscriptBody(t));
|
|
19
|
+
const previousHash = threadBodyHashes[t.id];
|
|
20
|
+
if (stored?.headSha === headSha && (previousHash === undefined || previousHash === bodyHash))
|
|
21
|
+
continue;
|
|
22
|
+
threadAttempts[t.id] = previousHash === bodyHash ? (threadAttempts[t.id] ?? 0) + 1 : 1;
|
|
23
|
+
threadBodyHashes[t.id] = bodyHash;
|
|
24
|
+
}
|
|
25
|
+
return { threadAttempts, threadBodyHashes };
|
|
26
|
+
}
|
|
11
27
|
export async function handleFixCode(ctx) {
|
|
12
28
|
const { base, report, opts, headSha, stallKey, prNumber, stallTimeoutSeconds, repoOwner, repoName, reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, } = ctx;
|
|
13
29
|
const failingChecks = report.checks.failing;
|
|
14
30
|
const stored = await readFixAttempts({ owner: repoOwner, repo: repoName, pr: prNumber });
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
if (isNewSha) {
|
|
18
|
-
for (const t of report.threads.actionable) {
|
|
19
|
-
currentAttempts[t.id] = (currentAttempts[t.id] ?? 0) + 1;
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
const escalateTriggers = checkEscalateTriggers(report.threads.actionable, currentAttempts);
|
|
31
|
+
const { threadAttempts, threadBodyHashes } = nextFixAttempts(stored, headSha, report.threads.actionable);
|
|
32
|
+
const escalateTriggers = checkEscalateTriggers(report.threads.actionable, threadAttempts);
|
|
23
33
|
if (escalateTriggers.triggers.length > 0) {
|
|
24
34
|
const escalateBase = {
|
|
25
35
|
triggers: escalateTriggers.triggers,
|
|
@@ -38,7 +48,7 @@ export async function handleFixCode(ctx) {
|
|
|
38
48
|
},
|
|
39
49
|
};
|
|
40
50
|
}
|
|
41
|
-
await writeFixAttempts({ owner: repoOwner, repo: repoName, pr: prNumber }, { headSha, threadAttempts
|
|
51
|
+
await writeFixAttempts({ owner: repoOwner, repo: repoName, pr: prNumber }, { headSha, threadAttempts, threadBodyHashes });
|
|
42
52
|
let cancelled = [];
|
|
43
53
|
if (!opts.noAutoCancelActionable) {
|
|
44
54
|
const uniqueRunIds = [
|
|
@@ -67,12 +77,6 @@ export async function handleFixCode(ctx) {
|
|
|
67
77
|
const commentMinimizeIds = report.comments.minimizeIds ?? actionableComments.map((c) => c.id);
|
|
68
78
|
const allCommentIds = [...commentMinimizeIds, ...reviewSummaryIds];
|
|
69
79
|
const resolveCommand = buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, changesRequestedReviews, checks, prNumber);
|
|
70
|
-
const overlappingReviewIds = resolveCommand.droppedDismissReviewIds ?? [];
|
|
71
|
-
if (overlappingReviewIds.length > 0) {
|
|
72
|
-
process.stderr.write(`pr-shepherd: resolve command overlap: ${overlappingReviewIds.length} ` +
|
|
73
|
-
`review IDs were also in minimize/comment IDs and were dropped from --dismiss-review-ids: ` +
|
|
74
|
-
`${overlappingReviewIds.join(", ")}\n`);
|
|
75
|
-
}
|
|
76
80
|
// Safety: if the base branch is unknown, escalate when a push is plausible — the agent
|
|
77
81
|
// would need the correct base to rebase safely. This is a conservative guard, not a
|
|
78
82
|
// prediction that the agent *will* push. Intentionally broader than `pushLikely` above:
|
|
@@ -65,7 +65,7 @@ export function buildFixInstructions(threads, actionableComments, checks, change
|
|
|
65
65
|
instructions.push(`Apply code fixes: read and edit each file referenced under \`## Review threads\` and \`## Actionable comments\` above.${suggestionFallback}`);
|
|
66
66
|
}
|
|
67
67
|
if (resolutionOnlyThreads.length > 0) {
|
|
68
|
-
instructions.push(`
|
|
68
|
+
instructions.push(`Review the threads under \`## Review threads to resolve\`. Human-authored threads are replied to by the \`resolve:\` command shown below; Shepherd does not resolve them. Bot/non-human threads are included in \`--resolve-thread-ids\`.`);
|
|
69
69
|
}
|
|
70
70
|
instructions.push(...buildFailingCheckInstructions(checks));
|
|
71
71
|
if (checks.some((c) => (c.annotations?.length ?? 0) > 0)) {
|
|
@@ -83,7 +83,7 @@ export function buildFixInstructions(threads, actionableComments, checks, change
|
|
|
83
83
|
substituteParts.push(`\`$HEAD_SHA\` with the pushed commit SHA (or \`$(git rev-parse HEAD)\` if you did not push)`);
|
|
84
84
|
}
|
|
85
85
|
if (resolveCommand.requiresDismissMessage) {
|
|
86
|
-
substituteParts.push(`\`$DISMISS_MESSAGE\` with a one-sentence description of what you changed`);
|
|
86
|
+
substituteParts.push(`\`$DISMISS_MESSAGE\` with a one-sentence reply/description of what you changed`);
|
|
87
87
|
}
|
|
88
88
|
const substituteHint = substituteParts.length > 0 ? `, substituting ${substituteParts.join(" and ")}` : "";
|
|
89
89
|
instructions.push(`Run the \`resolve:\` command shown above${substituteHint}.`);
|
|
@@ -105,7 +105,7 @@ export function buildFixInstructions(threads, actionableComments, checks, change
|
|
|
105
105
|
if (editedTotal > 0) {
|
|
106
106
|
instructions.push(`Items marked \`[edited since first look]\`, 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.`);
|
|
107
107
|
}
|
|
108
|
-
if (resolveCommand.hasMutations) {
|
|
108
|
+
if (resolveCommand.hasMutations || hasNonConflictHints || firstLookTotal > 0) {
|
|
109
109
|
instructions.push(buildShepherdJournalInstruction(prNumber, SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEM_HEADINGS));
|
|
110
110
|
}
|
|
111
111
|
instructions.push(FIX_INSTRUCTION_STOP);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { NOW } from "./iterate-test-support.mjs";
|
|
2
|
+
export function makeThread(overrides = {}) {
|
|
3
|
+
return {
|
|
4
|
+
id: "thread-1",
|
|
5
|
+
isResolved: false,
|
|
6
|
+
isOutdated: false,
|
|
7
|
+
isMinimized: false,
|
|
8
|
+
path: "src/foo.mts",
|
|
9
|
+
line: 10,
|
|
10
|
+
startLine: null,
|
|
11
|
+
author: "reviewer",
|
|
12
|
+
authorType: "Unknown",
|
|
13
|
+
body: "Fix this",
|
|
14
|
+
url: "",
|
|
15
|
+
createdAtUnix: NOW - 3600,
|
|
16
|
+
...overrides,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -23,9 +23,13 @@ vi.mock("../state/iterate-stall.mts", () => ({
|
|
|
23
23
|
readStallState: vi.fn().mockResolvedValue(null),
|
|
24
24
|
writeStallState: vi.fn().mockResolvedValue(undefined),
|
|
25
25
|
}));
|
|
26
|
-
vi.mock("../state/seen-comments.mts", () =>
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
vi.mock("../state/seen-comments.mts", async (importOriginal) => {
|
|
27
|
+
const actual = await importOriginal();
|
|
28
|
+
return {
|
|
29
|
+
...actual,
|
|
30
|
+
markSeen: vi.fn().mockResolvedValue(undefined),
|
|
31
|
+
};
|
|
32
|
+
});
|
|
29
33
|
const { mockLoadConfig } = vi.hoisted(() => ({ mockLoadConfig: vi.fn() }));
|
|
30
34
|
vi.mock("../config/load.mts", () => ({ loadConfig: mockLoadConfig }));
|
|
31
35
|
import { runIterate } from "./iterate/index.mjs";
|