pr-shepherd 0.22.0 → 0.24.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.
Files changed (72) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +87 -125
  3. package/bin/cli/args.mjs +2 -0
  4. package/bin/cli/fix-formatter.mjs +3 -1
  5. package/bin/cli/formatters.mjs +3 -45
  6. package/bin/cli/handlers.mjs +22 -1
  7. package/bin/cli/help-command-pages.mjs +27 -2
  8. package/bin/cli/help-top-page.mjs +2 -0
  9. package/bin/cli/iterate-instructions.mjs +1 -1
  10. package/bin/cli/list-formatters.mjs +5 -2
  11. package/bin/cli/mark-files-as-viewed-flags.mjs +34 -0
  12. package/bin/cli/mark-files-as-viewed-formatter.mjs +52 -0
  13. package/bin/cli/mutate-formatter.mjs +50 -0
  14. package/bin/cli-parser.mjs +11 -2
  15. package/bin/commands/check.mjs +25 -56
  16. package/bin/commands/commit-suggestion-instruction.mjs +1 -1
  17. package/bin/commands/iterate/classify.mjs +32 -31
  18. package/bin/commands/iterate/fix-code.mjs +23 -19
  19. package/bin/commands/iterate/index.mjs +4 -1
  20. package/bin/commands/iterate/render.mjs +6 -3
  21. package/bin/commands/iterate/stall.mjs +1 -1
  22. package/bin/commands/mark-files-as-viewed.mjs +220 -0
  23. package/bin/commands/resolve-instructions.mjs +5 -2
  24. package/bin/commands/resolve-mutate.mjs +49 -4
  25. package/bin/commands/resolve.mjs +21 -78
  26. package/bin/commands/shepherd-journal.mjs +1 -1
  27. package/bin/comments/authors.mjs +32 -0
  28. package/bin/comments/minimize-policy.mjs +8 -4
  29. package/bin/comments/pending-ops.mjs +6 -0
  30. package/bin/comments/resolve.mjs +28 -11
  31. package/bin/comments/review-thread-markers.mjs +18 -0
  32. package/bin/comments/review-visibility.mjs +14 -0
  33. package/bin/comments/thread-visibility.mjs +60 -0
  34. package/bin/comments/visible-comments.mjs +2 -2
  35. package/bin/config/load.mjs +7 -0
  36. package/bin/config.json +14 -0
  37. package/bin/github/batch-parser-helpers.mjs +3 -4
  38. package/bin/github/batch-parsers.mjs +8 -6
  39. package/bin/github/client.mjs +1 -2
  40. package/bin/github/gql/batch-pr.gql +3 -0
  41. package/bin/reporters/agent.mjs +2 -0
  42. package/bin/state/seen-comments.mjs +40 -4
  43. package/bin/threads/transcript.mjs +6 -6
  44. package/package.json +8 -4
  45. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  46. package/plugins/pr-shepherd/skills/mark-files-as-viewed/SKILL.md +31 -0
  47. package/bin/checks/triage.test-support.mjs +0 -60
  48. package/bin/cli/iterate-lean.test-support.mjs +0 -3
  49. package/bin/cli-parser.clean.test-support.mjs +0 -44
  50. package/bin/cli-parser.commit-suggestion.test-support.mjs +0 -65
  51. package/bin/cli-parser.iterate-fix.test-support.mjs +0 -43
  52. package/bin/cli-parser.iterate-fixtures.mjs +0 -75
  53. package/bin/cli-parser.iterate.test-support.mjs +0 -44
  54. package/bin/cli-parser.test-support.mjs +0 -42
  55. package/bin/commands/check.test-support.mjs +0 -147
  56. package/bin/commands/clean.test-support.mjs +0 -47
  57. package/bin/commands/commit-suggestion.apply.test-support.mjs +0 -87
  58. package/bin/commands/commit-suggestion.test-support.mjs +0 -112
  59. package/bin/commands/iterate-stall.test-support.mjs +0 -24
  60. package/bin/commands/iterate-test-support.mjs +0 -150
  61. package/bin/commands/iterate.fix-code-in-progress.test-support.mjs +0 -123
  62. package/bin/commands/poll.test-support.mjs +0 -77
  63. package/bin/commands/resolve.test-support.mjs +0 -114
  64. package/bin/commands/shepherd-journal.test-support.mjs +0 -7
  65. package/bin/comments/outdated.mjs +0 -15
  66. package/bin/comments/resolve.test-support.mjs +0 -39
  67. package/bin/github/batch-parsers.test-support.mjs +0 -66
  68. package/bin/github/batch.test-support.mjs +0 -66
  69. package/bin/github/client.test-support.mjs +0 -55
  70. package/bin/github/http.test-support.mjs +0 -51
  71. package/bin/state/seen-comments.test-support.mjs +0 -19
  72. package/bin/suggestions/patch.test-support.mjs +0 -2
@@ -0,0 +1,52 @@
1
+ export function formatMarkFilesAsViewedResult(result) {
2
+ const lines = [];
3
+ lines.push(`# PR #${result.prNumber} — Mark files as viewed (${result.markedPaths.length} marked)`);
4
+ lines.push("");
5
+ lines.push(`repo: ${result.repo}`);
6
+ appendPathSection(lines, "Matched files", result.matchedPaths);
7
+ appendPathSection(lines, "Marked viewed", result.markedPaths);
8
+ appendPathSection(lines, "Already viewed", result.alreadyViewedPaths);
9
+ appendPathSection(lines, "Missing from PR diff", result.missingPaths);
10
+ appendTextSection(lines, "Unmatched selectors", result.unmatchedSelectors);
11
+ if (result.rateLimit) {
12
+ const details = [
13
+ result.rateLimit.retryAfterSeconds !== undefined
14
+ ? `retry after ${result.rateLimit.retryAfterSeconds}s`
15
+ : null,
16
+ result.rateLimit.remaining !== undefined && result.rateLimit.limit !== undefined
17
+ ? `remaining ${result.rateLimit.remaining}/${result.rateLimit.limit}`
18
+ : null,
19
+ result.rateLimit.resetAt !== undefined
20
+ ? `reset at ${new Date(result.rateLimit.resetAt * 1000).toISOString()}`
21
+ : null,
22
+ ]
23
+ .filter(Boolean)
24
+ .join(", ");
25
+ lines.push("");
26
+ lines.push(`Stopped: GitHub rate limit hit — ${result.rateLimit.message}${details ? ` (${details})` : ""}`);
27
+ }
28
+ appendPathSection(lines, "Not marked due to rate limit", result.unmarkedPaths ?? []);
29
+ const errors = result.rateLimit
30
+ ? result.errors.filter((e) => !e.startsWith("rate limit:"))
31
+ : result.errors;
32
+ appendTextSection(lines, "Errors", errors);
33
+ if (result.matchedPaths.length === 0) {
34
+ lines.push("");
35
+ lines.push("No files matched.");
36
+ }
37
+ return lines.join("\n");
38
+ }
39
+ function appendPathSection(lines, label, paths) {
40
+ if (paths.length === 0)
41
+ return;
42
+ lines.push("");
43
+ lines.push(`## ${label} (${paths.length})`);
44
+ lines.push(paths.map((path) => `- \`${path}\``).join("\n"));
45
+ }
46
+ function appendTextSection(lines, label, values) {
47
+ if (values.length === 0)
48
+ return;
49
+ lines.push("");
50
+ lines.push(`## ${label} (${values.length})`);
51
+ lines.push(values.map((value) => `- ${value}`).join("\n"));
52
+ }
@@ -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
+ }
@@ -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
- * [--dismiss-review-ids Q] [--message MSG] [--require-sha SHA]
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,
@@ -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,10 @@ 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
+ import { markReviewInlineThreadMarkers } from "../comments/review-thread-markers.mjs";
18
+ import { normalizeBotUsernames } from "../comments/authors.mjs";
17
19
  export async function runCheck(opts) {
18
20
  const repo = await getRepoInfo();
19
21
  const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
@@ -43,51 +45,11 @@ export async function runCheck(opts) {
43
45
  const triagedBase = failing.length > 0 && !opts.skipTriage ? await triageFailingChecks(failing, repo) : failing;
44
46
  const stateKey = { owner: repo.owner, repo: repo.name, pr: prNumber };
45
47
  const seenMap = await loadSeenMap(stateKey);
48
+ const botUsernames = normalizeBotUsernames(config.botUsernames);
46
49
  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
50
  const minimizedCommentCandidates = batchData.comments.filter((c) => c.isMinimized);
62
- const visibleCommentClassification = classifyVisibleComments(batchData.comments, seenMap, config.iterate.minimizeComments);
63
- const autoResolvedIds = new Set(autoResolved.map((t) => t.id));
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
- ];
51
+ const visibleCommentClassification = classifyVisibleComments(batchData.comments, seenMap, config.iterate.minimizeComments, botUsernames);
52
+ const threadVisibility = classifyThreadVisibility(batchData.reviewThreads, seenMap, botUsernames);
91
53
  const firstLookComments = minimizedCommentCandidates.flatMap((c) => {
92
54
  const cls = classifyItem(c.id, c.body, seenMap);
93
55
  if (cls === "unchanged")
@@ -107,16 +69,23 @@ export async function runCheck(opts) {
107
69
  else
108
70
  seenSummaries.push(r);
109
71
  }
72
+ const changesRequestedReviewVisibility = classifyReviewsForDisplay(batchData.changesRequestedReviews, seenMap);
73
+ const approvedReviewVisibility = classifyReviewsForDisplay(batchData.approvedReviews, seenMap);
110
74
  await Promise.allSettled([
111
- ...firstLookThreads.map((t) => markSeen(stateKey, t.id, threadTranscriptBody(t))),
112
75
  ...firstLookComments.map((c) => markSeen(stateKey, c.id, c.body)),
76
+ ...threadVisibility.toMarkSeen.map((t) => markSeen(stateKey, t.id, threadTranscriptBody(t))),
113
77
  ...visibleCommentClassification.toMarkSeen.map((c) => markSeen(stateKey, c.id, c.body)),
114
78
  ...[...firstLookSummaries, ...editedSummaries].map((r) => markSeen(stateKey, r.id, r.body)),
79
+ ...changesRequestedReviewVisibility.toMarkSeen.map((r) => markSeen(stateKey, r.id, r.body)),
80
+ ...approvedReviewVisibility.toMarkSeen.map((r) => markSeen(stateKey, r.id, r.body)),
115
81
  ]);
116
- const resolutionOnlyThreads = unresolvedThreads.filter((t) => !autoResolvedIds.has(t.id) && (t.isOutdated || t.isMinimized));
117
- let status = computeStatus(verdict, activeThreads.length + resolutionOnlyThreads.length, visibleCommentClassification.actionable.length, mergeStatus, batchData.changesRequestedReviews.length);
82
+ await markReviewInlineThreadMarkers(stateKey, batchData.reviewThreads);
83
+ const changesRequestedReviews = changesRequestedReviewVisibility.visible;
84
+ const changesRequestedReviewCount = batchData.changesRequestedReviews.length;
85
+ const approvedReviews = approvedReviewVisibility.visible;
86
+ let status = computeStatus(verdict, threadVisibility.activeThreads.length + threadVisibility.resolutionOnlyThreads.length, visibleCommentClassification.actionable.length, mergeStatus, changesRequestedReviewCount);
118
87
  if (status === "READY" && !didRefreshMergeability) {
119
- const refreshed = await refreshReadyMergeability(prNumber, repo, batchData, verdict, activeThreads.length + resolutionOnlyThreads.length, visibleCommentClassification.actionable.length);
88
+ const refreshed = await refreshReadyMergeability(prNumber, repo, batchData, verdict, threadVisibility.activeThreads.length + threadVisibility.resolutionOnlyThreads.length, visibleCommentClassification.actionable.length);
120
89
  batchData = refreshed.batchData;
121
90
  mergeStatus = refreshed.mergeStatus;
122
91
  status = refreshed.status;
@@ -139,22 +108,22 @@ export async function runCheck(opts) {
139
108
  blockedByFilteredCheck,
140
109
  },
141
110
  threads: {
142
- actionable: activeThreads,
143
- resolutionOnly: resolutionOnlyThreads,
144
- autoResolved,
145
- autoResolveErrors,
146
- firstLook: firstLookThreads,
111
+ actionable: threadVisibility.activeThreads,
112
+ resolutionOnly: threadVisibility.resolutionOnlyThreads,
113
+ autoResolved: [],
114
+ autoResolveErrors: [],
115
+ firstLook: threadVisibility.firstLookThreads,
147
116
  },
148
117
  comments: {
149
118
  actionable: visibleCommentClassification.actionable,
150
119
  minimizeIds: visibleCommentClassification.minimizeIds,
151
120
  firstLook: firstLookComments,
152
121
  },
153
- changesRequestedReviews: batchData.changesRequestedReviews,
122
+ changesRequestedReviews,
154
123
  reviewSummaries: seenSummaries,
155
124
  firstLookSummaries,
156
125
  editedSummaries,
157
- approvedReviews: batchData.approvedReviews,
126
+ approvedReviews,
158
127
  branchProtection: batchData.branchProtection,
159
128
  };
160
129
  }
@@ -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. 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.`;
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 { isConfiguredBotAuthor, isHumanAuthor, } from "../../comments/authors.mjs";
3
4
  function dedupeIds(ids) {
4
5
  const seen = new Set();
5
6
  const out = [];
@@ -11,23 +12,28 @@ function dedupeIds(ids) {
11
12
  }
12
13
  return out;
13
14
  }
14
- export function classifyReviewSummaries(summaries, approvals, minimizeApprovals, minimizeComments = "all") {
15
+ export function classifyReviewSummaries(summaries, approvals, minimizeApprovals, minimizeComments = "all", botUsernames = new Set(), unresolvedThreads = []) {
16
+ const blockedReviewIds = new Set(unresolvedThreads.flatMap((t) => (t.reviewId !== undefined ? [t.reviewId] : [])));
15
17
  // First-look and seen summaries go into the minimize mutation; edited summaries do NOT —
16
18
  // they are already minimized server-side (body changed after minimize was applied).
17
19
  // First-look bodies are rendered so the agent sees them before the minimize happens.
18
20
  const minimizeIds = [...summaries.firstLook, ...summaries.seen]
19
- .filter((r) => shouldMinimizeAuthor(r.authorType, minimizeComments))
21
+ .filter((r) => shouldMinimizeAuthor(r.authorType, minimizeComments, r.author, botUsernames))
22
+ .filter((r) => !blockedReviewIds.has(r.id))
20
23
  .map((r) => r.id);
21
24
  if (minimizeApprovals) {
25
+ const surfacedApprovals = [];
22
26
  for (const r of approvals) {
23
- if (shouldMinimizeAuthor(r.authorType, minimizeComments))
27
+ if (shouldMinimizeAuthor(r.authorType, minimizeComments, r.author, botUsernames))
24
28
  minimizeIds.push(r.id);
29
+ else
30
+ surfacedApprovals.push(r);
25
31
  }
26
32
  return {
27
33
  minimizeIds,
28
34
  firstLookSummaries: summaries.firstLook,
29
35
  editedSummaries: summaries.edited,
30
- surfacedApprovals: [],
36
+ surfacedApprovals,
31
37
  };
32
38
  }
33
39
  return {
@@ -37,45 +43,40 @@ export function classifyReviewSummaries(summaries, approvals, minimizeApprovals,
37
43
  surfacedApprovals: approvals,
38
44
  };
39
45
  }
40
- export function buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, reviews, checks, prNumber) {
46
+ export function buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, _reviews, checks, prNumber, botUsernames = new Set()) {
41
47
  const argv = buildPrShepherdCommand(["resolve", String(prNumber)]).argv;
42
- const resolveThreadIds = dedupeIds(threads.map((t) => t.id));
43
- const threadIds = dedupeIds([...resolveThreadIds, ...resolutionOnlyThreads.map((t) => t.id)]);
44
- if (threadIds.length > 0) {
45
- argv.push("--resolve-thread-ids", threadIds.join(","));
48
+ const allThreads = [...threads, ...resolutionOnlyThreads];
49
+ const replyThreadIds = dedupeIds(allThreads
50
+ .filter((t) => isHumanAuthor(t) && !isConfiguredBotAuthor(t, botUsernames))
51
+ .map((t) => t.id));
52
+ const resolveThreadIds = dedupeIds(allThreads
53
+ .filter((t) => !isHumanAuthor(t) || isConfiguredBotAuthor(t, botUsernames))
54
+ .map((t) => t.id));
55
+ if (replyThreadIds.length > 0) {
56
+ argv.push("--reply-thread-ids", replyThreadIds.join(","));
57
+ argv.push("--message", "$DISMISS_MESSAGE");
58
+ }
59
+ if (resolveThreadIds.length > 0) {
60
+ argv.push("--resolve-thread-ids", resolveThreadIds.join(","));
46
61
  }
47
62
  if (allCommentIds.length > 0) {
48
63
  argv.push("--minimize-comment-ids", allCommentIds.join(","));
49
64
  }
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;
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
65
+ // hasMutations = we appended at least one reply, resolve, or minimize mutation. Returned explicitly
66
66
  // (rather than derived from argv.length) so callers don't couple to the
67
67
  // base-argv shape.
68
- const hasMutations = threadIds.length > 0 || allCommentIds.length > 0 || filteredReviewIds.length > 0;
68
+ const hasMutations = replyThreadIds.length > 0 || resolveThreadIds.length > 0 || allCommentIds.length > 0;
69
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);
70
+ // mutation that can race with a moving HEAD: replying after actionable
71
+ // thread fixes or addressing failing checks.
72
+ const hasCodeMutations = hasMutations && (threads.length > 0 || checks.length > 0);
73
73
  const requiresHeadSha = hasCodeMutations;
74
74
  return {
75
75
  argv,
76
76
  requiresHeadSha,
77
- requiresDismissMessage: hasDismiss,
78
- ...(droppedDismissReviewIds.length > 0 ? { droppedDismissReviewIds } : undefined),
77
+ requiresDismissMessage: replyThreadIds.length > 0,
78
+ ...(replyThreadIds.length > 0 ? { replyThreadIds } : undefined),
79
+ ...(resolveThreadIds.length > 0 ? { resolveThreadIds } : undefined),
79
80
  hasMutations,
80
81
  };
81
82
  }
@@ -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
- const { base, report, opts, headSha, stallKey, prNumber, stallTimeoutSeconds, repoOwner, repoName, reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, } = ctx;
28
+ const { base, report, opts, headSha, stallKey, prNumber, stallTimeoutSeconds, repoOwner, repoName, reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, botUsernames, } = ctx;
13
29
  const failingChecks = report.checks.failing;
14
30
  const stored = await readFixAttempts({ owner: repoOwner, repo: repoName, pr: prNumber });
15
- const isNewSha = stored?.headSha !== headSha;
16
- const currentAttempts = stored ? { ...stored.threadAttempts } : {};
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: currentAttempts });
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 = [
@@ -66,13 +76,7 @@ export async function handleFixCode(ctx) {
66
76
  const inProgressRunIds = pushLikely ? buildInProgressRunIds(report, cancelledSet) : [];
67
77
  const commentMinimizeIds = report.comments.minimizeIds ?? actionableComments.map((c) => c.id);
68
78
  const allCommentIds = [...commentMinimizeIds, ...reviewSummaryIds];
69
- 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
- }
79
+ const resolveCommand = buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, changesRequestedReviews, checks, prNumber, botUsernames);
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:
@@ -9,8 +9,10 @@ import { classifyReviewSummaries } from "./classify.mjs";
9
9
  import { applyStallGuard } from "./stall.mjs";
10
10
  import { clearStallState } from "../../state/iterate-stall.mjs";
11
11
  import { handleFixCode } from "./fix-code.mjs";
12
+ import { normalizeBotUsernames } from "../../comments/authors.mjs";
12
13
  export async function runIterate(opts) {
13
14
  const config = loadConfig();
15
+ const botUsernames = normalizeBotUsernames(config.botUsernames);
14
16
  const readyDelaySeconds = opts.readyDelaySeconds ?? config.watch.readyDelayMinutes * 60;
15
17
  const stallTimeoutSeconds = opts.stallTimeoutSeconds ?? config.iterate.stallTimeoutMinutes * 60;
16
18
  const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
@@ -56,7 +58,7 @@ export async function runIterate(opts) {
56
58
  firstLook: report.firstLookSummaries,
57
59
  seen: report.reviewSummaries,
58
60
  edited: report.editedSummaries,
59
- }, report.approvedReviews, config.iterate.minimizeApprovals, config.iterate.minimizeComments);
61
+ }, report.approvedReviews, config.iterate.minimizeApprovals, config.iterate.minimizeComments, botUsernames, [...report.threads.actionable, ...report.threads.resolutionOnly]);
60
62
  const hasActionableWork = report.threads.actionable.length > 0 ||
61
63
  report.threads.resolutionOnly.length > 0 ||
62
64
  report.threads.firstLook.length > 0 ||
@@ -122,6 +124,7 @@ export async function runIterate(opts) {
122
124
  firstLookSummaries,
123
125
  editedSummaries,
124
126
  surfacedApprovals,
127
+ botUsernames,
125
128
  });
126
129
  }
127
130
  const canMarkReady = report.status === "READY" &&
@@ -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(`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.`);
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)) {
@@ -78,12 +78,15 @@ export function buildFixInstructions(threads, actionableComments, checks, change
78
78
  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.`);
79
79
  }
80
80
  if (resolveCommand.hasMutations) {
81
+ if ((resolveCommand.replyThreadIds?.length ?? 0) > 0) {
82
+ instructions.push(`Before running the \`resolve:\` command, remove any thread from \`--reply-thread-ids\` if the latest visible comment in that thread is your own prior Shepherd reply. Do not reply to your own comments.`);
83
+ }
81
84
  const substituteParts = [];
82
85
  if (resolveCommand.requiresHeadSha) {
83
86
  substituteParts.push(`\`$HEAD_SHA\` with the pushed commit SHA (or \`$(git rev-parse HEAD)\` if you did not push)`);
84
87
  }
85
88
  if (resolveCommand.requiresDismissMessage) {
86
- substituteParts.push(`\`$DISMISS_MESSAGE\` with a one-sentence description of what you changed`);
89
+ substituteParts.push(`\`$DISMISS_MESSAGE\` with a one-sentence reply/description of what you changed`);
87
90
  }
88
91
  const substituteHint = substituteParts.length > 0 ? `, substituting ${substituteParts.join(" and ")}` : "";
89
92
  instructions.push(`Run the \`resolve:\` command shown above${substituteHint}.`);
@@ -105,7 +108,7 @@ export function buildFixInstructions(threads, actionableComments, checks, change
105
108
  if (editedTotal > 0) {
106
109
  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
110
  }
108
- if (resolveCommand.hasMutations) {
111
+ if (resolveCommand.hasMutations || hasNonConflictHints || firstLookTotal > 0) {
109
112
  instructions.push(buildShepherdJournalInstruction(prNumber, SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEM_HEADINGS));
110
113
  }
111
114
  instructions.push(FIX_INSTRUCTION_STOP);
@@ -1,7 +1,7 @@
1
1
  import { readStallState, writeStallState } from "../../state/iterate-stall.mjs";
2
2
  import { toAgentThread, toAgentComment, toAgentStalledCheck } from "../../reporters/agent.mjs";
3
3
  import { buildEscalateSuggestion, buildEscalateHumanMessage } from "./escalate.mjs";
4
- export function computeStallFingerprint(action, headSha, base, report, reviewSummaryIds) {
4
+ function computeStallFingerprint(action, headSha, base, report, reviewSummaryIds) {
5
5
  const checks = [
6
6
  ...report.checks.failing.map((f) => `failing:${f.name}:${f.conclusion}`),
7
7
  ...report.checks.inProgress.map((p) => `inProgress:${p.name}`),