pr-shepherd 0.23.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 (55) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +2 -1
  3. package/bin/cli/fix-formatter.mjs +2 -1
  4. package/bin/cli/iterate-instructions.mjs +1 -1
  5. package/bin/cli/list-formatters.mjs +3 -2
  6. package/bin/commands/check.mjs +6 -2
  7. package/bin/commands/iterate/classify.mjs +13 -7
  8. package/bin/commands/iterate/fix-code.mjs +2 -2
  9. package/bin/commands/iterate/index.mjs +4 -1
  10. package/bin/commands/iterate/render.mjs +3 -0
  11. package/bin/commands/iterate/stall.mjs +1 -1
  12. package/bin/commands/resolve-instructions.mjs +5 -2
  13. package/bin/commands/resolve-mutate.mjs +14 -6
  14. package/bin/commands/resolve.mjs +7 -3
  15. package/bin/comments/authors.mjs +19 -1
  16. package/bin/comments/minimize-policy.mjs +6 -5
  17. package/bin/comments/review-thread-markers.mjs +18 -0
  18. package/bin/comments/thread-visibility.mjs +5 -5
  19. package/bin/comments/visible-comments.mjs +2 -2
  20. package/bin/config/load.mjs +7 -0
  21. package/bin/config.json +14 -0
  22. package/bin/github/batch-parsers.mjs +2 -0
  23. package/bin/github/client.mjs +1 -2
  24. package/bin/github/gql/batch-pr.gql +3 -0
  25. package/bin/reporters/agent.mjs +1 -0
  26. package/bin/state/seen-comments.mjs +40 -4
  27. package/package.json +8 -4
  28. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  29. package/bin/checks/triage.test-support.mjs +0 -60
  30. package/bin/cli/iterate-lean.test-support.mjs +0 -3
  31. package/bin/cli-parser.clean.test-support.mjs +0 -44
  32. package/bin/cli-parser.commit-suggestion.test-support.mjs +0 -65
  33. package/bin/cli-parser.iterate-fix.test-support.mjs +0 -43
  34. package/bin/cli-parser.iterate-fixtures.mjs +0 -75
  35. package/bin/cli-parser.iterate.test-support.mjs +0 -44
  36. package/bin/cli-parser.test-support.mjs +0 -47
  37. package/bin/commands/check.test-support.mjs +0 -147
  38. package/bin/commands/clean.test-support.mjs +0 -47
  39. package/bin/commands/commit-suggestion.apply.test-support.mjs +0 -87
  40. package/bin/commands/commit-suggestion.test-support.mjs +0 -112
  41. package/bin/commands/iterate-stall.test-support.mjs +0 -24
  42. package/bin/commands/iterate-test-support.mjs +0 -150
  43. package/bin/commands/iterate-thread-test-support.mjs +0 -18
  44. package/bin/commands/iterate.fix-code-in-progress.test-support.mjs +0 -127
  45. package/bin/commands/poll.test-support.mjs +0 -77
  46. package/bin/commands/resolve.test-support.mjs +0 -116
  47. package/bin/commands/shepherd-journal.test-support.mjs +0 -7
  48. package/bin/comments/outdated.mjs +0 -15
  49. package/bin/comments/resolve.test-support.mjs +0 -44
  50. package/bin/github/batch-parsers.test-support.mjs +0 -66
  51. package/bin/github/batch.test-support.mjs +0 -66
  52. package/bin/github/client.test-support.mjs +0 -55
  53. package/bin/github/http.test-support.mjs +0 -51
  54. package/bin/state/seen-comments.test-support.mjs +0 -19
  55. 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.24.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.
@@ -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, ""));
@@ -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
@@ -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,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}`),
@@ -51,8 +51,11 @@ export function buildFetchInstructions(prNumber, result) {
51
51
  : reviewSummaries.length > 0
52
52
  ? ` Review-summary IDs (\`PRR_…\` from \`## Review summaries\`) go into \`--minimize-comment-ids\`.`
53
53
  : "";
54
- const resolveCommand = `${buildPrShepherdCommand(["resolve", String(prNumber)]).text} [--resolve-thread-ids <ids>] [--minimize-comment-ids <ids>] [--dismiss-review-ids <ids> --message "<reason>"]`;
55
- instructions.push(`Run \`${resolveCommand}\` with only the non-empty flag subsets. Skip the command entirely if all three ID lists are empty.${requireShaHint}${dismissNote}`);
54
+ const resolveCommand = `${buildPrShepherdCommand(["resolve", String(prNumber)]).text} [--reply-thread-ids <ids>] [--resolve-thread-ids <ids>] [--minimize-comment-ids <ids>] [--dismiss-review-ids <ids>] [--message "<reason>"]`;
55
+ if (actionableThreads.length > 0 || resolutionOnlyThreads.length > 0) {
56
+ instructions.push(`Do not reply to your own thread comments. If the latest visible comment in a thread is your own prior Shepherd reply, leave that thread out of \`--reply-thread-ids\`.`);
57
+ }
58
+ instructions.push(`Run \`${resolveCommand}\` with only the non-empty flag subsets. Skip the command entirely if all ID lists are empty.${requireShaHint}${dismissNote}`);
56
59
  instructions.push(buildShepherdJournalInstruction(prNumber, SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEMS));
57
60
  instructions.push(`Report: echo the CLI's mutation output, then one line per Acknowledged item: \`Acknowledged <id> (@<author>): <reason>\`. If any fetched item was neither resolved nor acknowledged, stop and escalate: "<N> item(s) fetched but not acted on or acknowledged — need human direction before closing".`);
58
61
  return instructions;
@@ -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;
@@ -8,6 +8,8 @@ import { loadSeenMap, markSeen, classifyItem } from "../state/seen-comments.mjs"
8
8
  import { threadTranscriptBody } from "../threads/transcript.mjs";
9
9
  import { classifyThreadVisibility } from "../comments/thread-visibility.mjs";
10
10
  import { classifyReviewsForDisplay } from "../comments/review-visibility.mjs";
11
+ import { markReviewInlineThreadMarkers } from "../comments/review-thread-markers.mjs";
12
+ import { normalizeBotUsernames } from "../comments/authors.mjs";
11
13
  export { runResolveMutate } from "./resolve-mutate.mjs";
12
14
  export async function runResolveFetch(opts) {
13
15
  const repo = await getRepoInfo();
@@ -19,7 +21,9 @@ export async function runResolveFetch(opts) {
19
21
  const stateKey = { owner: repo.owner, repo: repo.name, pr: prNumber };
20
22
  const minimizedCommentCandidates = data.comments.filter((c) => c.isMinimized);
21
23
  const seenMap = await loadSeenMap(stateKey);
22
- const threadVisibility = classifyThreadVisibility(data.reviewThreads, seenMap);
24
+ const cfg = loadConfig();
25
+ const botUsernames = normalizeBotUsernames(cfg.botUsernames);
26
+ const threadVisibility = classifyThreadVisibility(data.reviewThreads, seenMap, botUsernames);
23
27
  const unseenMinimizedComments = [];
24
28
  const editedMinimizedComments = [];
25
29
  for (const c of minimizedCommentCandidates) {
@@ -29,8 +33,7 @@ export async function runResolveFetch(opts) {
29
33
  else if (cls === "edited")
30
34
  editedMinimizedComments.push(c);
31
35
  }
32
- const cfg = loadConfig();
33
- const visibleCommentClassification = classifyVisibleComments(data.comments, seenMap, cfg.iterate?.minimizeComments);
36
+ const visibleCommentClassification = classifyVisibleComments(data.comments, seenMap, cfg.iterate?.minimizeComments, botUsernames);
34
37
  const changesRequestedReviewVisibility = classifyReviewsForDisplay(data.changesRequestedReviews, seenMap);
35
38
  const reviewSummaryVisibility = cfg.resolve.fetchReviewSummaries
36
39
  ? classifyReviewsForDisplay(data.reviewSummaries, seenMap)
@@ -58,6 +61,7 @@ export async function runResolveFetch(opts) {
58
61
  ...changesRequestedReviewVisibility.toMarkSeen.map((r) => markSeen(stateKey, r.id, r.body)),
59
62
  ...reviewSummaryVisibility.toMarkSeen.map((r) => markSeen(stateKey, r.id, r.body)),
60
63
  ]);
64
+ await markReviewInlineThreadMarkers(stateKey, data.reviewThreads);
61
65
  const result = {
62
66
  prNumber,
63
67
  actionableThreads,
@@ -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
+ }
@@ -1,5 +1,6 @@
1
1
  import { classifyItem } from "../state/seen-comments.mjs";
2
2
  import { threadTranscriptBody } from "../threads/transcript.mjs";
3
+ import { isConfiguredBotAuthor } from "./authors.mjs";
3
4
  function withEdited(thread, edited) {
4
5
  return edited ? { ...thread, edited: true } : thread;
5
6
  }
@@ -15,20 +16,19 @@ function classifyFirstLookThread(thread, seenMap, firstLookStatus) {
15
16
  return null;
16
17
  return { ...visible, firstLookStatus };
17
18
  }
18
- export function classifyThreadVisibility(threads, seenMap) {
19
+ export function classifyThreadVisibility(threads, seenMap, botUsernames = new Set()) {
19
20
  const unresolvedThreads = threads.filter((t) => !t.isResolved);
20
21
  const activeThreads = unresolvedThreads
21
22
  .filter((t) => !t.isOutdated && !t.isMinimized)
22
23
  .flatMap((t) => {
24
+ if (isConfiguredBotAuthor(t, botUsernames))
25
+ return [t];
23
26
  const visible = classifyVisibleThread(t, seenMap);
24
27
  return visible ? [visible] : [];
25
28
  });
26
29
  const resolutionOnlyThreads = unresolvedThreads
27
30
  .filter((t) => t.isOutdated || t.isMinimized)
28
- .flatMap((t) => {
29
- const visible = classifyVisibleThread(t, seenMap);
30
- return visible ? [visible] : [];
31
- });
31
+ .map((t) => classifyVisibleThread(t, seenMap) ?? t);
32
32
  const firstLookThreads = [
33
33
  ...threads.flatMap((t) => {
34
34
  if (!t.isOutdated)
@@ -1,11 +1,11 @@
1
1
  import { shouldMinimizeAuthor } from "./minimize-policy.mjs";
2
2
  import { classifyItem } from "../state/seen-comments.mjs";
3
- export function classifyVisibleComments(comments, seenMap, minimizeComments) {
3
+ export function classifyVisibleComments(comments, seenMap, minimizeComments, botUsernames = new Set()) {
4
4
  const actionable = [];
5
5
  const minimizeIds = [];
6
6
  const toMarkSeen = [];
7
7
  for (const c of comments.filter((comment) => !comment.isMinimized)) {
8
- if (shouldMinimizeAuthor(c.authorType, minimizeComments, c.author)) {
8
+ if (shouldMinimizeAuthor(c.authorType, minimizeComments, c.author, botUsernames)) {
9
9
  actionable.push(c);
10
10
  minimizeIds.push(c.id);
11
11
  continue;
@@ -45,6 +45,12 @@ function parseMinimizeCommentsPolicy(value) {
45
45
  return value;
46
46
  throw new Error(`Invalid config: iterate.minimizeComments must be one of "all", "bots", "users", or "none", got ${JSON.stringify(value)}`);
47
47
  }
48
+ function parseBotUsernames(value) {
49
+ if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
50
+ throw new Error(`Invalid config: botUsernames must be an array of strings`);
51
+ }
52
+ return value;
53
+ }
48
54
  const defaults = builtins;
49
55
  const configCache = new Map();
50
56
  export function loadConfig() {
@@ -60,6 +66,7 @@ export function loadConfig() {
60
66
  const raw = readFileSync(rcPath, "utf8");
61
67
  const parsed = (parse(raw) ?? {});
62
68
  const config = deepMerge(defaults, parsed);
69
+ config.botUsernames = parseBotUsernames(config.botUsernames);
63
70
  config.iterate.minimizeComments = parseMinimizeCommentsPolicy(config.iterate.minimizeComments);
64
71
  configCache.set(cwd, config);
65
72
  return config;
package/bin/config.json CHANGED
@@ -1,4 +1,18 @@
1
1
  {
2
+ "botUsernames": [
3
+ "chatgpt-connector",
4
+ "openai-codex",
5
+ "codex",
6
+ "claude",
7
+ "copilot-pull-request-reviewer",
8
+ "gemini-code-assist",
9
+ "coderabbitai",
10
+ "greptile-apps",
11
+ "qodo-merge-pro",
12
+ "deepsource-io",
13
+ "sonarqubecloud",
14
+ "what-the-diff"
15
+ ],
2
16
  "iterate": {
3
17
  "fixAttemptsPerThread": 3,
4
18
  "stallTimeoutMinutes": 60,
@@ -13,6 +13,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
13
13
  const comments = t.comments.nodes.map((c) => ({
14
14
  id: c.id,
15
15
  isMinimized: c.isMinimized,
16
+ ...(c.pullRequestReview?.id ? { reviewId: c.pullRequestReview.id } : undefined),
16
17
  author: c.author?.login ?? "unknown",
17
18
  authorType: mapAuthorType(c.author?.__typename, c.author?.login),
18
19
  body: c.body,
@@ -27,6 +28,7 @@ export function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes,
27
28
  path: t.path ?? comment?.path ?? null,
28
29
  line: t.line ?? comment?.line ?? null,
29
30
  startLine: t.startLine ?? comment?.startLine ?? null,
31
+ ...(comment?.pullRequestReview?.id ? { reviewId: comment.pullRequestReview.id } : undefined),
30
32
  author: comment?.author?.login ?? "unknown",
31
33
  authorType: mapAuthorType(comment?.author?.__typename, comment?.author?.login),
32
34
  body: comment?.body ?? "",
@@ -7,9 +7,8 @@
7
7
  */
8
8
  import { execFile as execFileCb } from "node:child_process";
9
9
  import { promisify } from "node:util";
10
- import { graphql as httpGraphql, rest, GitHubRequestError, } from "./http.mjs";
10
+ import { graphql as httpGraphql, rest } from "./http.mjs";
11
11
  import { PR_NUMBER_BY_BRANCH_QUERY, GET_PR_HEAD_SHA_QUERY } from "./queries.mjs";
12
- export { GitHubRequestError };
13
12
  const execFile = promisify(execFileCb);
14
13
  // ---------------------------------------------------------------------------
15
14
  // GraphQL — thin re-exports so callers don't need to import http.mts directly
@@ -83,6 +83,9 @@ query BatchPr(
83
83
  __typename
84
84
  login
85
85
  }
86
+ pullRequestReview {
87
+ id
88
+ }
86
89
  body
87
90
  path
88
91
  line
@@ -13,6 +13,7 @@ export function toAgentThread(t) {
13
13
  const suggestion = extractSuggestion(t) ?? undefined;
14
14
  return {
15
15
  id: t.id,
16
+ ...(t.reviewId !== undefined && { reviewId: t.reviewId }),
16
17
  path: t.path,
17
18
  line: t.line,
18
19
  ...(t.line !== null &&
@@ -1,3 +1,4 @@
1
+ /* eslint-disable max-lines */
1
2
  import { readFile, writeFile, rename, unlink, mkdir, access, readdir } from "node:fs/promises";
2
3
  import { join, dirname } from "node:path";
3
4
  import { createHash, randomUUID } from "node:crypto";
@@ -23,8 +24,14 @@ export function classifyItem(id, body, map) {
23
24
  const m = map.get(id);
24
25
  if (!m)
25
26
  return "new";
26
- if (typeof m.bodyHash === "string" && m.bodyHash !== hashBody(body))
27
+ const currentHash = hashBody(body);
28
+ if (typeof m.previousBodyHash === "string" && m.previousBodyHash === currentHash) {
29
+ return "unchanged";
30
+ }
31
+ if (typeof m.bodyHash === "string" && m.bodyHash !== currentHash)
27
32
  return "edited";
33
+ if (m.bodyHash === undefined && Array.isArray(m.inlineThreadIds))
34
+ return "new";
28
35
  return "unchanged";
29
36
  }
30
37
  /**
@@ -94,11 +101,31 @@ export async function hasSeen(key, id) {
94
101
  * All errors are silently swallowed — the marker is best-effort.
95
102
  */
96
103
  export async function markSeen(key, id, body) {
104
+ await writeSeenMarker(key, id, { bodyHash: hashBody(body) });
105
+ }
106
+ export async function markReviewInlineThreads(key, reviewId, inlineThreadIds) {
107
+ await writeSeenMarker(key, reviewId, {
108
+ inlineThreadIds: [...new Set(inlineThreadIds)].sort((a, b) => a.localeCompare(b)),
109
+ });
110
+ }
111
+ /**
112
+ * Write a marker after Shepherd successfully replies to a review thread.
113
+ *
114
+ * `previousBody` suppresses stale GitHub fetches that have not yet included the new reply.
115
+ * `body` suppresses the expected final transcript once GitHub includes Shepherd's reply.
116
+ */
117
+ export async function markReplySeen(key, id, previousBody, body, replyBody) {
118
+ await writeSeenMarker(key, id, {
119
+ bodyHash: hashBody(body),
120
+ previousBodyHash: hashBody(previousBody),
121
+ replyBodyHash: hashBody(replyBody),
122
+ });
123
+ }
124
+ async function writeSeenMarker(key, id, markerFields) {
97
125
  let tmp;
98
126
  try {
99
127
  const path = resolvePath(key, id);
100
128
  await mkdir(dirname(path), { recursive: true });
101
- const newHash = hashBody(body);
102
129
  let existing = null;
103
130
  try {
104
131
  const raw = await readFile(path, "utf8");
@@ -107,14 +134,15 @@ export async function markSeen(key, id, body) {
107
134
  catch {
108
135
  // no existing marker — will create below
109
136
  }
110
- if (existing !== null && existing.bodyHash === newHash)
137
+ const unchanged = Object.entries(markerFields).every(([field, value]) => markerFieldEqual(existing?.[field], value));
138
+ if (existing !== null && unchanged)
111
139
  return;
112
140
  const seenAt = existing?.seenAt ?? Date.now();
113
141
  tmp = `${path}.${randomUUID()}.tmp`;
114
142
  // Store `id` in the payload so loadSeenMap can key by the original ID
115
143
  // rather than the filename, guarding against case-insensitive filesystems
116
144
  // (e.g. macOS APFS) where IDs differing only in case would collide.
117
- await writeFile(tmp, JSON.stringify({ seenAt, bodyHash: newHash, id }), "utf8");
145
+ await writeFile(tmp, JSON.stringify({ ...existing, seenAt, ...markerFields, id }), "utf8");
118
146
  await rename(tmp, path);
119
147
  tmp = undefined;
120
148
  }
@@ -132,6 +160,14 @@ export async function markSeen(key, id, body) {
132
160
  }
133
161
  }
134
162
  }
163
+ function markerFieldEqual(left, right) {
164
+ if (Object.is(left, right))
165
+ return true;
166
+ if (Array.isArray(left) && Array.isArray(right)) {
167
+ return left.length === right.length && left.every((value, index) => value === right[index]);
168
+ }
169
+ return false;
170
+ }
135
171
  /** Read the full marker for inspection (returns null on miss or error). */
136
172
  export async function readSeenMarker(key, id) {
137
173
  try {