pr-shepherd 0.23.0 → 0.25.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 (61) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +4 -2
  3. package/bin/cli/args.mjs +0 -1
  4. package/bin/cli/fix-formatter.mjs +2 -1
  5. package/bin/cli/formatters.mjs +0 -62
  6. package/bin/cli/help-command-pages.mjs +4 -10
  7. package/bin/cli/help-top-page.mjs +1 -1
  8. package/bin/cli/iterate-instructions.mjs +1 -1
  9. package/bin/cli/list-formatters.mjs +3 -2
  10. package/bin/cli-parser.mjs +28 -27
  11. package/bin/commands/check.mjs +6 -2
  12. package/bin/commands/commit-suggestion-instruction.mjs +3 -3
  13. package/bin/commands/iterate/classify.mjs +13 -7
  14. package/bin/commands/iterate/fix-code.mjs +2 -2
  15. package/bin/commands/iterate/index.mjs +4 -1
  16. package/bin/commands/iterate/render.mjs +3 -0
  17. package/bin/commands/iterate/stall.mjs +1 -1
  18. package/bin/commands/resolve-mutate.mjs +14 -6
  19. package/bin/commands/resolve.mjs +0 -72
  20. package/bin/comments/authors.mjs +19 -1
  21. package/bin/comments/minimize-policy.mjs +6 -5
  22. package/bin/comments/review-thread-markers.mjs +18 -0
  23. package/bin/comments/thread-visibility.mjs +5 -5
  24. package/bin/comments/visible-comments.mjs +2 -2
  25. package/bin/config/load.mjs +7 -0
  26. package/bin/config.json +15 -2
  27. package/bin/github/batch-parsers.mjs +2 -0
  28. package/bin/github/client.mjs +1 -2
  29. package/bin/github/gql/batch-pr.gql +3 -0
  30. package/bin/reporters/agent.mjs +1 -0
  31. package/bin/state/seen-comments.mjs +40 -4
  32. package/package.json +9 -5
  33. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  34. package/bin/checks/triage.test-support.mjs +0 -60
  35. package/bin/cli/iterate-lean.test-support.mjs +0 -3
  36. package/bin/cli-parser.clean.test-support.mjs +0 -44
  37. package/bin/cli-parser.commit-suggestion.test-support.mjs +0 -65
  38. package/bin/cli-parser.iterate-fix.test-support.mjs +0 -43
  39. package/bin/cli-parser.iterate-fixtures.mjs +0 -75
  40. package/bin/cli-parser.iterate.test-support.mjs +0 -44
  41. package/bin/cli-parser.test-support.mjs +0 -47
  42. package/bin/commands/check.test-support.mjs +0 -147
  43. package/bin/commands/clean.test-support.mjs +0 -47
  44. package/bin/commands/commit-suggestion.apply.test-support.mjs +0 -87
  45. package/bin/commands/commit-suggestion.test-support.mjs +0 -112
  46. package/bin/commands/iterate-stall.test-support.mjs +0 -24
  47. package/bin/commands/iterate-test-support.mjs +0 -150
  48. package/bin/commands/iterate-thread-test-support.mjs +0 -18
  49. package/bin/commands/iterate.fix-code-in-progress.test-support.mjs +0 -127
  50. package/bin/commands/poll.test-support.mjs +0 -77
  51. package/bin/commands/resolve-instructions.mjs +0 -59
  52. package/bin/commands/resolve.test-support.mjs +0 -116
  53. package/bin/commands/shepherd-journal.test-support.mjs +0 -7
  54. package/bin/comments/outdated.mjs +0 -15
  55. package/bin/comments/resolve.test-support.mjs +0 -44
  56. package/bin/github/batch-parsers.test-support.mjs +0 -66
  57. package/bin/github/batch.test-support.mjs +0 -66
  58. package/bin/github/client.test-support.mjs +0 -55
  59. package/bin/github/http.test-support.mjs +0 -51
  60. package/bin/state/seen-comments.test-support.mjs +0 -19
  61. package/bin/suggestions/patch.test-support.mjs +0 -2
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
3
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
4
- "version": "0.23.0",
4
+ "version": "0.25.0",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
7
7
  "email": "jonathanrichardong@gmail.com"
package/README.md CHANGED
@@ -59,7 +59,8 @@ See [docs/actions.md](docs/actions.md) for the complete output contract.
59
59
  This system is opinionated and works best with PRs that use required status checks and conversation resolution.
60
60
 
61
61
  - Human-authored threads are replied to, not resolved or minimized by Shepherd.
62
- - Bot/non-human threads, PR comments, and review summaries can be resolved or minimized when eligible.
62
+ - Detected bots and configured `botUsernames` review threads are returned until resolved; bot/non-human threads, PR comments, and review summaries can be resolved or minimized when eligible. Review summaries are not minimized while known inline child threads from that review remain unresolved.
63
+ - Agents must not reply to their own latest thread reply; generated instructions call this out before `--reply-thread-ids` mutations.
63
64
  - Every review thread/comment/review summary is surfaced at least once, even if already outdated, resolved, or minimized; edited items re-surface through seen markers.
64
65
  - Draft PRs can be marked ready automatically when clean; disable with `actions.autoMarkReady: false` or `--no-auto-mark-ready`.
65
66
  - The CLI never performs git mutations. It emits instructions; the caller commits, rebases, pushes, and handles repository hooks.
@@ -96,10 +97,11 @@ pr-shepherd poll 42 # explicit poll command
96
97
  ### Resolve Review Items
97
98
 
98
99
  ```sh
99
- pr-shepherd resolve 42 --fetch
100
100
  pr-shepherd resolve 42 --reply-thread-ids PRRT_abc --message "Renamed the variable for clarity." --require-sha "$(git rev-parse HEAD)"
101
101
  ```
102
102
 
103
+ Use `pr-shepherd iterate 42` or `pr-shepherd 42` to fetch the next PR action. `resolve` requires at least one action flag and only applies explicit review-state mutations.
104
+
103
105
  ### Apply One Suggestion Thread
104
106
 
105
107
  ```sh
package/bin/cli/args.mjs CHANGED
@@ -25,7 +25,6 @@ const FLAGS_WITH_VALUES = new Set([
25
25
  // for PR-number detection — so removed flags don't silently cause their
26
26
  // numeric value to be misidentified as the PR number.
27
27
  const BOOLEAN_FLAGS = new Set([
28
- "--fetch",
29
28
  "--no-auto-mark-ready",
30
29
  "--no-auto-cancel-actionable",
31
30
  "--dry-run",
@@ -12,9 +12,10 @@ export function formatFixCodeResult(header, result, opts) {
12
12
  const lineLabel = renderLineRange(t.startLine, t.line);
13
13
  const loc = t.path ? `\`${t.path}:${lineLabel}\`` : "(no location)";
14
14
  const heading = t.url ? `[threadId=${t.id}](${t.url})` : `\`threadId=${t.id}\``;
15
+ const reviewMarker = t.reviewId ? ` [reviewId=${t.reviewId}]` : "";
15
16
  const suggestionMarker = t.suggestion ? " [suggestion]" : "";
16
17
  const editedMarker = t.edited ? " [edited since first look]" : "";
17
- sections.push(`### ${heading} — ${loc} (${renderAuthor(t.author, t.authorType)})${suggestionMarker}${editedMarker}`);
18
+ sections.push(`### ${heading} — ${loc} (${renderAuthor(t.author, t.authorType)})${reviewMarker}${suggestionMarker}${editedMarker}`);
18
19
  sections.push(renderThreadConversation(t));
19
20
  if (t.suggestion) {
20
21
  sections.push(renderSuggestionBlock(t.suggestion, ""));
@@ -4,68 +4,6 @@ export { formatCleanResult } from "./clean-formatter.mjs";
4
4
  export { formatMarkFilesAsViewedResult } from "./mark-files-as-viewed-formatter.mjs";
5
5
  export { formatMutateResult } from "./mutate-formatter.mjs";
6
6
  import { safeFence } from "./fence.mjs";
7
- import { renderThreadBullet, renderCommentBullet, renderReviewBullet, renderThreadResolutionStatusTag, buildFirstLookBullets, renderEditedCommentTag, } from "./list-formatters.mjs";
8
- import { joinSections } from "../util/markdown.mjs";
9
- export function formatFetchResult(result) {
10
- const activeTotal = result.actionableThreads.length +
11
- result.resolutionOnlyThreads.length +
12
- result.actionableComments.length +
13
- result.changesRequestedReviews.length +
14
- result.reviewSummaries.length;
15
- const firstLookTotal = result.firstLookThreads.length + result.firstLookComments.length;
16
- const total = activeTotal + firstLookTotal;
17
- const headingParts = [];
18
- if (activeTotal > 0)
19
- headingParts.push(`${activeTotal} actionable`);
20
- if (firstLookTotal > 0)
21
- headingParts.push(`${firstLookTotal} first-look`);
22
- const headingSuffix = headingParts.length > 0 ? headingParts.join(", ") : "0 actionable";
23
- const sections = [`# PR #${result.prNumber} — Resolve fetch (${headingSuffix})`];
24
- if (result.actionableThreads.length > 0) {
25
- sections.push(`## Actionable Review Threads (${result.actionableThreads.length})` +
26
- (result.commitSuggestionsEnabled ? " [commit-suggestions: enabled]" : ""));
27
- sections.push(result.actionableThreads
28
- .map((t) => renderThreadBullet(t, { renderSuggestion: true }))
29
- .join("\n\n"));
30
- }
31
- if (result.resolutionOnlyThreads.length > 0) {
32
- sections.push(`## Review threads to resolve (${result.resolutionOnlyThreads.length})`);
33
- sections.push(result.resolutionOnlyThreads
34
- .map((t) => renderThreadBullet(t, { statusTag: renderThreadResolutionStatusTag(t) }))
35
- .join("\n"));
36
- }
37
- if (result.actionableComments.length > 0) {
38
- sections.push(`## Actionable PR Comments (${result.actionableComments.length})`);
39
- sections.push(result.actionableComments
40
- .map((c) => renderCommentBullet(c, { statusTag: renderEditedCommentTag(c) }))
41
- .join("\n"));
42
- }
43
- if (result.changesRequestedReviews.length > 0) {
44
- sections.push(`## Pending CHANGES_REQUESTED reviews (${result.changesRequestedReviews.length})`);
45
- sections.push(result.changesRequestedReviews.map((r) => renderReviewBullet(r)).join("\n"));
46
- }
47
- if (result.reviewSummaries.length > 0) {
48
- sections.push(`## Review summaries (${result.reviewSummaries.length})`);
49
- sections.push(result.reviewSummaries.map((r) => renderReviewBullet(r, { includeBody: true })).join("\n"));
50
- }
51
- if (firstLookTotal > 0) {
52
- sections.push(`## First-look items (${firstLookTotal}) — acknowledge status before acting`);
53
- const resolutionOnlyIds = new Set(result.resolutionOnlyThreads.map((t) => t.id));
54
- sections.push(buildFirstLookBullets(result.firstLookThreads, resolutionOnlyIds, result.firstLookComments).join("\n"));
55
- }
56
- sections.push("## Summary");
57
- sections.push(total === 0
58
- ? "0 actionable, 0 first-look — all items seen"
59
- : [
60
- activeTotal > 0 ? `${activeTotal} actionable` : null,
61
- firstLookTotal > 0 ? `${firstLookTotal} first-look` : null,
62
- ]
63
- .filter(Boolean)
64
- .join(", "));
65
- sections.push("## Instructions");
66
- sections.push(result.instructions.map((inst, i) => `${i + 1}. ${inst}`).join("\n"));
67
- return joinSections(sections);
68
- }
69
7
  export function formatCommitSuggestionResult(result) {
70
8
  const lines = [];
71
9
  const range = result.startLine === result.endLine
@@ -1,24 +1,15 @@
1
1
  export const COMMAND_USAGE = {
2
2
  resolve: `pr-shepherd resolve
3
3
 
4
- Fetch actionable review items or apply GitHub review-state mutations after fixes.
4
+ Apply GitHub review-state mutations after fixes.
5
5
 
6
6
  Usage:
7
- pr-shepherd resolve [PR] [--fetch] [--format text|json]
8
7
  pr-shepherd resolve [PR] --reply-thread-ids A,B --message MSG
9
8
  pr-shepherd resolve [PR] --resolve-thread-ids A,B [--minimize-comment-ids X,Y]
10
9
  [--dismiss-review-ids Q] [--message MSG]
11
10
  [--require-sha SHA] [--format text|json]
12
11
 
13
- Modes:
14
- fetch Default when no mutation IDs are provided. Fetches review threads,
15
- comments, first-look items, and instructions.
16
- mutate Runs when any mutation ID flag is present. Resolves threads,
17
- replies to human threads, resolves non-human/manual thread IDs,
18
- minimizes non-human comments/review summaries, and dismisses non-human reviews.
19
-
20
12
  Flags:
21
- --fetch Force fetch mode.
22
13
  --resolve-thread-ids <ids> Comma-separated review thread IDs to resolve.
23
14
  Human-authored thread IDs are skipped; use --reply-thread-ids.
24
15
  --reply-thread-ids <ids> Comma-separated human review thread IDs to reply to.
@@ -30,6 +21,9 @@ Flags:
30
21
  --format text|json Output format. Default: text.
31
22
  --help, -h Print this help and exit before GitHub I/O.
32
23
 
24
+ At least one non-empty action flag is required:
25
+ --reply-thread-ids, --resolve-thread-ids, --minimize-comment-ids, or --dismiss-review-ids.
26
+
33
27
  PR may be a number or GitHub pull request URL. When omitted, the current branch PR is inferred.
34
28
  Exit code: 0 on success; 1 on validation, lookup, or mutation failure.`,
35
29
  "commit-suggestion": `pr-shepherd commit-suggestion
@@ -18,7 +18,7 @@ Commands:
18
18
  [PR] Poll until non-WAIT or timeout. This is the default command.
19
19
  iterate Run one iterate tick (single-tick alias).
20
20
  poll Re-run iterate while the action is WAIT, then print the final tick.
21
- resolve Fetch actionable review items, or resolve/minimize/dismiss IDs.
21
+ resolve Apply review-state mutations (requires at least one action flag).
22
22
  commit-suggestion Convert one GitHub suggestion thread into a patch and commit instructions.
23
23
  mark-files-as-viewed Mark PR changed files as viewed in GitHub.
24
24
  clean Remove pr-shepherd state files.
@@ -32,7 +32,7 @@ export function adaptFixCodeInstructions(instructions, pr, readyDelaySuffix) {
32
32
  export function adaptIterateLog(log) {
33
33
  return log.replace(/\s+—\s+\d+s until auto-cancel/g, "");
34
34
  }
35
- export function buildIterateCommand(pr, readyDelaySuffix) {
35
+ function buildIterateCommand(pr, readyDelaySuffix) {
36
36
  const suffix = readyDelaySuffix?.trim();
37
37
  return buildPrShepherdCommand([String(pr), ...(suffix ? ["--ready-delay", suffix] : [])]).text;
38
38
  }
@@ -9,7 +9,7 @@ export function renderBodyPreview(body) {
9
9
  const firstLine = normalizedBody.split("\n")[0]?.trim() ?? "";
10
10
  return firstLine.slice(0, BODY_PREVIEW_MAX);
11
11
  }
12
- export function renderFirstLookStatusTag(t) {
12
+ function renderFirstLookStatusTag(t) {
13
13
  const editedSuffix = t.edited ? ", edited" : "";
14
14
  return t.autoResolved
15
15
  ? `[status: outdated, auto-resolved${editedSuffix}]`
@@ -28,8 +28,9 @@ export function renderThreadBullet(t, opts = {}) {
28
28
  : "`(no location)`";
29
29
  const suggestionMarker = t.suggestion ? " [suggestion]" : "";
30
30
  const editedMarker = t.edited && !opts.suppressEditedMarker ? " [edited since first look]" : "";
31
+ const reviewMarker = t.reviewId ? ` [reviewId=${t.reviewId}]` : "";
31
32
  const statusSuffix = opts.statusTag ? ` ${opts.statusTag}` : "";
32
- const bulletLine = `- \`threadId=${t.id}\`${link} ${loc} (${renderAuthor(t.author, t.authorType)})${suggestionMarker}${editedMarker}${statusSuffix}`;
33
+ const bulletLine = `- \`threadId=${t.id}\`${link} ${loc} (${renderAuthor(t.author, t.authorType)})${reviewMarker}${suggestionMarker}${editedMarker}${statusSuffix}`;
33
34
  if (!opts.noBody && (!t.comments || t.comments.length === 0)) {
34
35
  const legacyLine = `${bulletLine}: ${renderBodyPreview(t.body)}`;
35
36
  return t.suggestion && opts.renderSuggestion
@@ -6,7 +6,7 @@
6
6
  * pr-shepherd [PR] [--interval 45s] [--timeout 4m] [--format text|json] [--ready-delay Nm]
7
7
  * [--stall-timeout <duration>] [--no-auto-mark-ready]
8
8
  * [--no-auto-cancel-actionable]
9
- * pr-shepherd resolve [PR] [--fetch] [--resolve-thread-ids A,B] [--minimize-comment-ids X,Y]
9
+ * pr-shepherd resolve [PR] [--resolve-thread-ids A,B] [--minimize-comment-ids X,Y]
10
10
  * [--reply-thread-ids A,B] [--dismiss-review-ids Q]
11
11
  * [--message MSG] [--require-sha SHA]
12
12
  * pr-shepherd commit-suggestion [PR] --thread-id ID --message MSG [--description DESC]
@@ -22,12 +22,12 @@
22
22
  * pr-shepherd clean <pr|branch|current|repo|all> [value] [--dry-run] [--format text|json]
23
23
  */
24
24
  import { readFileSync } from "node:fs";
25
- import { runResolveFetch, runResolveMutate } from "./commands/resolve.mjs";
25
+ import { runResolveMutate } from "./commands/resolve.mjs";
26
26
  import { runLogFile } from "./commands/log-file.mjs";
27
27
  import { parseCommonArgs, getFlag, hasFlag, parseList } from "./cli/args.mjs";
28
28
  import { isDefaultPollInvocation, validateDefaultPollArgs } from "./cli/default-poll.mjs";
29
29
  import { USAGE, maybePrintHelp } from "./cli/help.mjs";
30
- import { formatFetchResult, formatMutateResult } from "./cli/formatters.mjs";
30
+ import { formatMutateResult } from "./cli/formatters.mjs";
31
31
  import { handleClean, handleCommitSuggestion, handleIterate, handleMarkFilesAsViewed, } from "./cli/handlers.mjs";
32
32
  import { handlePoll } from "./cli/poll-handler.mjs";
33
33
  import { setupLog } from "./log/setup.mjs";
@@ -130,30 +130,31 @@ async function handleResolve(args) {
130
130
  const dismissReviewIds = parseList(getFlag(extra, "--dismiss-review-ids"));
131
131
  const dismissMessage = getFlag(extra, "--message") ?? undefined;
132
132
  const requireSha = getFlag(extra, "--require-sha") ?? undefined;
133
- const fetchMode = hasFlag(extra, "--fetch") ||
134
- (resolveThreadIds.length === 0 &&
135
- replyThreadIds.length === 0 &&
136
- minimizeCommentIds.length === 0 &&
137
- dismissReviewIds.length === 0);
138
- if (fetchMode) {
139
- const result = await runResolveFetch({ ...globalOpts, prNumber });
140
- process.stdout.write(globalOpts.format === "json"
141
- ? `${JSON.stringify(result, null, 2)}\n`
142
- : `${formatFetchResult(result)}\n`);
133
+ if (hasFlag(extra, "--fetch")) {
134
+ process.stderr.write("pr-shepherd: resolve: --fetch has been removed; run pr-shepherd iterate or poll to fetch the next action.\n");
135
+ process.exitCode = 1;
136
+ return;
143
137
  }
144
- else {
145
- const result = await runResolveMutate({
146
- ...globalOpts,
147
- prNumber,
148
- resolveThreadIds,
149
- replyThreadIds,
150
- minimizeCommentIds,
151
- dismissReviewIds,
152
- dismissMessage,
153
- requireSha,
154
- });
155
- process.stdout.write(globalOpts.format === "json"
156
- ? `${JSON.stringify(result, null, 2)}\n`
157
- : `${formatMutateResult(result)}\n`);
138
+ const hasAction = resolveThreadIds.length > 0 ||
139
+ replyThreadIds.length > 0 ||
140
+ minimizeCommentIds.length > 0 ||
141
+ dismissReviewIds.length > 0;
142
+ if (!hasAction) {
143
+ process.stderr.write("pr-shepherd: resolve: an action flag is required (--reply-thread-ids, --resolve-thread-ids, --minimize-comment-ids, or --dismiss-review-ids).\n");
144
+ process.exitCode = 1;
145
+ return;
158
146
  }
147
+ const result = await runResolveMutate({
148
+ ...globalOpts,
149
+ prNumber,
150
+ resolveThreadIds,
151
+ replyThreadIds,
152
+ minimizeCommentIds,
153
+ dismissReviewIds,
154
+ dismissMessage,
155
+ requireSha,
156
+ });
157
+ process.stdout.write(globalOpts.format === "json"
158
+ ? `${JSON.stringify(result, null, 2)}\n`
159
+ : `${formatMutateResult(result)}\n`);
159
160
  }
@@ -14,6 +14,8 @@ import { loadSeenMap, markSeen, classifyItem } from "../state/seen-comments.mjs"
14
14
  import { threadTranscriptBody } from "../threads/transcript.mjs";
15
15
  import { classifyThreadVisibility } from "../comments/thread-visibility.mjs";
16
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,10 +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
50
  const minimizedCommentCandidates = batchData.comments.filter((c) => c.isMinimized);
48
- const visibleCommentClassification = classifyVisibleComments(batchData.comments, seenMap, config.iterate.minimizeComments);
49
- const threadVisibility = classifyThreadVisibility(batchData.reviewThreads, seenMap);
51
+ const visibleCommentClassification = classifyVisibleComments(batchData.comments, seenMap, config.iterate.minimizeComments, botUsernames);
52
+ const threadVisibility = classifyThreadVisibility(batchData.reviewThreads, seenMap, botUsernames);
50
53
  const firstLookComments = minimizedCommentCandidates.flatMap((c) => {
51
54
  const cls = classifyItem(c.id, c.body, seenMap);
52
55
  if (cls === "unchanged")
@@ -76,6 +79,7 @@ export async function runCheck(opts) {
76
79
  ...changesRequestedReviewVisibility.toMarkSeen.map((r) => markSeen(stateKey, r.id, r.body)),
77
80
  ...approvedReviewVisibility.toMarkSeen.map((r) => markSeen(stateKey, r.id, r.body)),
78
81
  ]);
82
+ await markReviewInlineThreadMarkers(stateKey, batchData.reviewThreads);
79
83
  const changesRequestedReviews = changesRequestedReviewVisibility.visible;
80
84
  const changesRequestedReviewCount = batchData.changesRequestedReviews.length;
81
85
  const approvedReviews = approvedReviewVisibility.visible;
@@ -1,9 +1,9 @@
1
1
  import { buildPrShepherdCommand } from "../cli/runner.mjs";
2
2
  /**
3
3
  * Build the `commit-suggestion` instruction step for agent consumers.
4
- * Shared between `fix_code` (iterate) and `resolve --fetch` paths.
5
- * @param sectionName - The markdown section heading where suggestion threads appear
6
- * (e.g. `"## Review threads"` for iterate, `"## Actionable Review Threads"` for resolve).
4
+ * Currently emitted by iterate `fix_code` for suggestion review threads.
5
+ * @param sectionName - The markdown section heading where suggestion threads appear,
6
+ * e.g. `"## Review threads"`.
7
7
  * @param includeDriftHint - Whether to add the trailing note about drift on failed apply.
8
8
  */
9
9
  export function buildCommitSuggestionInstruction(prNumber, sectionName, includeDriftHint) {
@@ -1,6 +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
+ import { isConfiguredBotAuthor, isHumanAuthor, } from "../../comments/authors.mjs";
4
4
  function dedupeIds(ids) {
5
5
  const seen = new Set();
6
6
  const out = [];
@@ -12,17 +12,19 @@ function dedupeIds(ids) {
12
12
  }
13
13
  return out;
14
14
  }
15
- 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] : [])));
16
17
  // First-look and seen summaries go into the minimize mutation; edited summaries do NOT —
17
18
  // they are already minimized server-side (body changed after minimize was applied).
18
19
  // First-look bodies are rendered so the agent sees them before the minimize happens.
19
20
  const minimizeIds = [...summaries.firstLook, ...summaries.seen]
20
- .filter((r) => shouldMinimizeAuthor(r.authorType, minimizeComments, r.author))
21
+ .filter((r) => shouldMinimizeAuthor(r.authorType, minimizeComments, r.author, botUsernames))
22
+ .filter((r) => !blockedReviewIds.has(r.id))
21
23
  .map((r) => r.id);
22
24
  if (minimizeApprovals) {
23
25
  const surfacedApprovals = [];
24
26
  for (const r of approvals) {
25
- if (shouldMinimizeAuthor(r.authorType, minimizeComments, r.author))
27
+ if (shouldMinimizeAuthor(r.authorType, minimizeComments, r.author, botUsernames))
26
28
  minimizeIds.push(r.id);
27
29
  else
28
30
  surfacedApprovals.push(r);
@@ -41,11 +43,15 @@ export function classifyReviewSummaries(summaries, approvals, minimizeApprovals,
41
43
  surfacedApprovals: approvals,
42
44
  };
43
45
  }
44
- export function buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, _reviews, checks, prNumber) {
46
+ export function buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, _reviews, checks, prNumber, botUsernames = new Set()) {
45
47
  const argv = buildPrShepherdCommand(["resolve", String(prNumber)]).argv;
46
48
  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
+ 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));
49
55
  if (replyThreadIds.length > 0) {
50
56
  argv.push("--reply-thread-ids", replyThreadIds.join(","));
51
57
  argv.push("--message", "$DISMISS_MESSAGE");
@@ -25,7 +25,7 @@ function nextFixAttempts(stored, headSha, threads) {
25
25
  return { threadAttempts, threadBodyHashes };
26
26
  }
27
27
  export async function handleFixCode(ctx) {
28
- 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;
29
29
  const failingChecks = report.checks.failing;
30
30
  const stored = await readFixAttempts({ owner: repoOwner, repo: repoName, pr: prNumber });
31
31
  const { threadAttempts, threadBodyHashes } = nextFixAttempts(stored, headSha, report.threads.actionable);
@@ -76,7 +76,7 @@ export async function handleFixCode(ctx) {
76
76
  const inProgressRunIds = pushLikely ? buildInProgressRunIds(report, cancelledSet) : [];
77
77
  const commentMinimizeIds = report.comments.minimizeIds ?? actionableComments.map((c) => c.id);
78
78
  const allCommentIds = [...commentMinimizeIds, ...reviewSummaryIds];
79
- const resolveCommand = buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, changesRequestedReviews, checks, prNumber);
79
+ const resolveCommand = buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, changesRequestedReviews, checks, prNumber, botUsernames);
80
80
  // Safety: if the base branch is unknown, escalate when a push is plausible — the agent
81
81
  // would need the correct base to rebase safely. This is a conservative guard, not a
82
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" &&
@@ -78,6 +78,9 @@ 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)`);
@@ -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}`),
@@ -1,8 +1,9 @@
1
1
  import { getRepoInfo, getCurrentPrNumber } from "../github/client.mjs";
2
2
  import { applyResolveOptions } from "../comments/resolve.mjs";
3
3
  import { fetchPrBatch } from "../github/batch.mjs";
4
- import { isHumanAuthor } from "../comments/authors.mjs";
5
- import { markSeen } from "../state/seen-comments.mjs";
4
+ import { loadConfig } from "../config/load.mjs";
5
+ import { isConfiguredBotAuthor, isHumanAuthor, normalizeBotUsernames, } from "../comments/authors.mjs";
6
+ import { markReplySeen } from "../state/seen-comments.mjs";
6
7
  import { threadTranscriptBody } from "../threads/transcript.mjs";
7
8
  export async function runResolveMutate(opts) {
8
9
  const repo = await getRepoInfo();
@@ -11,11 +12,17 @@ export async function runResolveMutate(opts) {
11
12
  throw new Error("No open PR found for current branch. Pass a PR number explicitly.");
12
13
  }
13
14
  const { data } = await fetchPrBatch(prNumber, repo, { paginateApprovedReviews: true });
15
+ const config = loadConfig();
16
+ const botUsernames = normalizeBotUsernames(config.botUsernames);
14
17
  const threadById = new Map(data.reviewThreads.map((t) => [t.id, t]));
15
- const humanThreadIds = new Set(data.reviewThreads.filter(isHumanAuthor).map((t) => t.id));
16
- const humanCommentIds = new Set(data.comments.filter(isHumanAuthor).map((c) => c.id));
18
+ const humanThreadIds = new Set(data.reviewThreads
19
+ .filter((t) => isHumanAuthor(t) && !isConfiguredBotAuthor(t, botUsernames))
20
+ .map((t) => t.id));
21
+ const humanCommentIds = new Set(data.comments
22
+ .filter((c) => isHumanAuthor(c) && !isConfiguredBotAuthor(c, botUsernames))
23
+ .map((c) => c.id));
17
24
  const humanReviewIds = new Set([...data.reviewSummaries, ...data.approvedReviews, ...data.changesRequestedReviews]
18
- .filter(isHumanAuthor)
25
+ .filter((r) => isHumanAuthor(r) && !isConfiguredBotAuthor(r, botUsernames))
19
26
  .map((r) => r.id));
20
27
  const resolveThreadIds = (opts.resolveThreadIds ?? []).filter((id) => !humanThreadIds.has(id));
21
28
  const skippedHumanResolves = (opts.resolveThreadIds ?? []).filter((id) => humanThreadIds.has(id));
@@ -46,7 +53,8 @@ export async function runResolveMutate(opts) {
46
53
  const thread = threadById.get(id);
47
54
  if (!thread)
48
55
  return Promise.resolve();
49
- return markSeen({ owner: repo.owner, repo: repo.name, pr: prNumber }, id, threadTranscriptBody(thread, [opts.dismissMessage]));
56
+ const previousBody = threadTranscriptBody(thread);
57
+ return markReplySeen({ owner: repo.owner, repo: repo.name, pr: prNumber }, id, previousBody, threadTranscriptBody(thread, [opts.dismissMessage]), opts.dismissMessage);
50
58
  }));
51
59
  }
52
60
  return result;
@@ -1,73 +1 @@
1
- import { getRepoInfo, getCurrentPrNumber } from "../github/client.mjs";
2
- import { fetchPrBatch } from "../github/batch.mjs";
3
- import { loadConfig } from "../config/load.mjs";
4
- import { classifyVisibleComments } from "../comments/visible-comments.mjs";
5
- import { extractSuggestion } from "../suggestions/extract.mjs";
6
- import { buildFetchInstructions } from "./resolve-instructions.mjs";
7
- import { loadSeenMap, markSeen, classifyItem } from "../state/seen-comments.mjs";
8
- import { threadTranscriptBody } from "../threads/transcript.mjs";
9
- import { classifyThreadVisibility } from "../comments/thread-visibility.mjs";
10
- import { classifyReviewsForDisplay } from "../comments/review-visibility.mjs";
11
1
  export { runResolveMutate } from "./resolve-mutate.mjs";
12
- export async function runResolveFetch(opts) {
13
- const repo = await getRepoInfo();
14
- const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
15
- if (prNumber === null) {
16
- throw new Error("No open PR found for current branch. Pass a PR number explicitly.");
17
- }
18
- const { data } = await fetchPrBatch(prNumber, repo);
19
- const stateKey = { owner: repo.owner, repo: repo.name, pr: prNumber };
20
- const minimizedCommentCandidates = data.comments.filter((c) => c.isMinimized);
21
- const seenMap = await loadSeenMap(stateKey);
22
- const threadVisibility = classifyThreadVisibility(data.reviewThreads, seenMap);
23
- const unseenMinimizedComments = [];
24
- const editedMinimizedComments = [];
25
- for (const c of minimizedCommentCandidates) {
26
- const cls = classifyItem(c.id, c.body, seenMap);
27
- if (cls === "new")
28
- unseenMinimizedComments.push(c);
29
- else if (cls === "edited")
30
- editedMinimizedComments.push(c);
31
- }
32
- const cfg = loadConfig();
33
- const visibleCommentClassification = classifyVisibleComments(data.comments, seenMap, cfg.iterate?.minimizeComments);
34
- const changesRequestedReviewVisibility = classifyReviewsForDisplay(data.changesRequestedReviews, seenMap);
35
- const reviewSummaryVisibility = cfg.resolve.fetchReviewSummaries
36
- ? classifyReviewsForDisplay(data.reviewSummaries, seenMap)
37
- : { visible: [], toMarkSeen: [] };
38
- const actionableThreads = threadVisibility.activeThreads.map(({ isResolved: _r, isOutdated: _o, ...rest }) => {
39
- const thread = rest;
40
- const suggestion = extractSuggestion(rest);
41
- if (suggestion)
42
- thread.suggestion = suggestion;
43
- return thread;
44
- });
45
- const firstLookComments = [
46
- ...unseenMinimizedComments.map((c) => ({ ...c, firstLookStatus: "minimized" })),
47
- ...editedMinimizedComments.map((c) => ({
48
- ...c,
49
- firstLookStatus: "minimized",
50
- edited: true,
51
- })),
52
- ];
53
- // Mark new and edited items as seen (best-effort — markSeen never throws).
54
- await Promise.allSettled([
55
- ...threadVisibility.toMarkSeen.map((t) => markSeen(stateKey, t.id, threadTranscriptBody(t))),
56
- ...firstLookComments.map((c) => markSeen(stateKey, c.id, c.body)),
57
- ...visibleCommentClassification.toMarkSeen.map((c) => markSeen(stateKey, c.id, c.body)),
58
- ...changesRequestedReviewVisibility.toMarkSeen.map((r) => markSeen(stateKey, r.id, r.body)),
59
- ...reviewSummaryVisibility.toMarkSeen.map((r) => markSeen(stateKey, r.id, r.body)),
60
- ]);
61
- const result = {
62
- prNumber,
63
- actionableThreads,
64
- resolutionOnlyThreads: threadVisibility.resolutionOnlyThreads,
65
- firstLookThreads: threadVisibility.firstLookThreads,
66
- actionableComments: visibleCommentClassification.actionable,
67
- firstLookComments,
68
- changesRequestedReviews: changesRequestedReviewVisibility.visible,
69
- reviewSummaries: reviewSummaryVisibility.visible,
70
- commitSuggestionsEnabled: cfg.actions.commitSuggestions,
71
- };
72
- return { ...result, instructions: buildFetchInstructions(prNumber, result) };
73
- }
@@ -1,6 +1,15 @@
1
- export function isBotLogin(login) {
1
+ function isBotLogin(login) {
2
2
  return (login ?? "").toLowerCase().includes("[bot]");
3
3
  }
4
+ function normalizeBotUsername(login) {
5
+ return (login ?? "")
6
+ .trim()
7
+ .toLowerCase()
8
+ .replace(/\[bot\]$/i, "");
9
+ }
10
+ export function normalizeBotUsernames(botUsernames = []) {
11
+ return new Set((botUsernames ?? []).map((name) => normalizeBotUsername(name)));
12
+ }
4
13
  export function normalizeAuthorType(typeName, login) {
5
14
  if (isBotLogin(login))
6
15
  return "Bot";
@@ -12,3 +21,12 @@ export function isHumanAuthor(author) {
12
21
  const login = author.author ?? author.login ?? "";
13
22
  return author.authorType === "User" && !isBotLogin(login);
14
23
  }
24
+ export function isConfiguredBotAuthor(author, botUsernames = new Set()) {
25
+ const login = author.author ?? author.login ?? "";
26
+ if (author.authorType === "Bot" || isBotLogin(login))
27
+ return true;
28
+ const normalized = normalizeBotUsername(login);
29
+ if (normalized === "")
30
+ return false;
31
+ return botUsernames.has(normalized);
32
+ }
@@ -1,13 +1,14 @@
1
- import { isHumanAuthor } from "./authors.mjs";
2
- export function shouldMinimizeAuthor(authorType, policy, author) {
3
- if (isHumanAuthor({ author, authorType }))
1
+ import { isConfiguredBotAuthor, isHumanAuthor } from "./authors.mjs";
2
+ export function shouldMinimizeAuthor(authorType, policy, author, botUsernames = new Set()) {
3
+ const isConfiguredBot = isConfiguredBotAuthor({ author, authorType }, botUsernames);
4
+ if (isHumanAuthor({ author, authorType }) && !isConfiguredBot)
4
5
  return false;
5
6
  switch (policy) {
6
7
  case undefined:
7
8
  case "all":
8
- return authorType !== "User";
9
+ return authorType !== "User" || isConfiguredBot;
9
10
  case "bots":
10
- return authorType === "Bot";
11
+ return isConfiguredBot;
11
12
  case "users":
12
13
  return false;
13
14
  case "none":
@@ -0,0 +1,18 @@
1
+ import { markReviewInlineThreads } from "../state/seen-comments.mjs";
2
+ function groupInlineThreadIdsByReview(threads) {
3
+ const byReview = new Map();
4
+ for (const thread of threads) {
5
+ if (thread.reviewId === undefined)
6
+ continue;
7
+ const ids = byReview.get(thread.reviewId) ?? new Set();
8
+ ids.add(thread.id);
9
+ byReview.set(thread.reviewId, ids);
10
+ }
11
+ return new Map([...byReview.entries()].map(([reviewId, ids]) => [
12
+ reviewId,
13
+ [...ids].sort((a, b) => a.localeCompare(b)),
14
+ ]));
15
+ }
16
+ export async function markReviewInlineThreadMarkers(key, threads) {
17
+ await Promise.allSettled([...groupInlineThreadIdsByReview(threads)].map(([reviewId, inlineThreadIds]) => markReviewInlineThreads(key, reviewId, inlineThreadIds)));
18
+ }