pr-shepherd 0.46.4 → 0.46.6

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 (40) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +5 -2
  3. package/bin/api.d.mts +1 -1
  4. package/bin/checks/triage.mjs +12 -1
  5. package/bin/cli/api-usage-formatter.mjs +1 -1
  6. package/bin/cli/body-truncate.d.mts +12 -0
  7. package/bin/cli/body-truncate.mjs +123 -0
  8. package/bin/cli/fix-formatter-extra.d.mts +2 -1
  9. package/bin/cli/fix-formatter-extra.mjs +18 -3
  10. package/bin/cli/fix-formatter.d.mts +3 -1
  11. package/bin/cli/fix-formatter.mjs +39 -13
  12. package/bin/cli/iterate-checks-formatter.mjs +8 -6
  13. package/bin/cli/iterate-formatter.mjs +10 -9
  14. package/bin/cli/iterate-instructions.mjs +3 -3
  15. package/bin/cli/iterate-lean.mjs +2 -0
  16. package/bin/cli/iterate-merge-formatter.d.mts +3 -1
  17. package/bin/cli/iterate-merge-formatter.mjs +15 -0
  18. package/bin/cli/list-formatters.d.mts +4 -3
  19. package/bin/cli/list-formatters.mjs +22 -14
  20. package/bin/commands/check-annotations.d.mts +15 -0
  21. package/bin/commands/check-annotations.mjs +23 -1
  22. package/bin/commands/check.d.mts +1 -0
  23. package/bin/commands/check.mjs +68 -32
  24. package/bin/commands/iterate/check-instructions.mjs +4 -4
  25. package/bin/commands/iterate/index.mjs +18 -5
  26. package/bin/commands/iterate/merge-state.d.mts +6 -1
  27. package/bin/commands/iterate/merge-state.mjs +42 -1
  28. package/bin/commands/iterate/render.mjs +2 -2
  29. package/bin/config/load.d.mts +10 -0
  30. package/bin/config/load.mjs +17 -1
  31. package/bin/config.json +4 -2
  32. package/bin/mcp/server.mjs +18 -6
  33. package/bin/quota-warning.mjs +1 -1
  34. package/bin/types/iterate.d.mts +10 -0
  35. package/bin/types/merge-queue.d.mts +16 -0
  36. package/package.json +9 -6
  37. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  38. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  39. package/plugins/pr-shepherd/.mcp.json +1 -1
  40. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +11 -11
@@ -1,5 +1,6 @@
1
1
  import { renderLineRange, renderSuggestionBlock } from "./suggestion-renderer.mjs";
2
2
  import { threadComments } from "../threads/transcript.mjs";
3
+ import { truncateBody, BODY_TRUNCATE_MAX_CHARS, NESTED_BODY_TRUNCATE_MAX_CHARS, } from "./body-truncate.mjs";
3
4
  const BODY_PREVIEW_MAX = 100;
4
5
  export function renderAuthor(author, authorType, authorAssociation, viewerDidAuthor) {
5
6
  return [`@${author}`, authorType, authorAssociation, viewerDidAuthor ? "viewer-authored" : null]
@@ -41,48 +42,54 @@ export function renderThreadBullet(t, opts = {}) {
41
42
  }
42
43
  const parts = [bulletLine];
43
44
  if (!opts.noBody) {
44
- parts.push(renderThreadCommentBullets(t));
45
+ parts.push(renderThreadCommentBullets(t, opts.verbose));
45
46
  }
46
47
  if (t.suggestion && opts.renderSuggestion) {
47
48
  parts.push(renderSuggestionBlock(t.suggestion));
48
49
  }
49
50
  return parts.join("\n");
50
51
  }
51
- export function renderThreadConversation(t) {
52
- if (!t.comments || t.comments.length === 0)
53
- return blockquote(t.body);
52
+ export function renderThreadConversation(t, verbose = false) {
53
+ const topCap = verbose ? undefined : BODY_TRUNCATE_MAX_CHARS;
54
+ if (!t.comments || t.comments.length === 0) {
55
+ return blockquote(t.body, topCap, t.url);
56
+ }
57
+ const nestedCap = verbose ? undefined : NESTED_BODY_TRUNCATE_MAX_CHARS;
54
58
  return threadComments(t)
55
- .map((c) => {
59
+ .map((c, i) => {
56
60
  const heading = c.id
57
61
  ? c.url
58
62
  ? `#### [commentId=${c.id}](${c.url}) (${renderAuthor(c.author, c.authorType, c.authorAssociation, c.viewerDidAuthor)})`
59
63
  : `#### \`commentId=${c.id}\` (${renderAuthor(c.author, c.authorType, c.authorAssociation, c.viewerDidAuthor)})`
60
64
  : `#### (${renderAuthor(c.author, c.authorType, c.authorAssociation, c.viewerDidAuthor)})`;
61
- return `${heading}\n\n${blockquote(c.body)}`;
65
+ return `${heading}\n\n${blockquote(c.body, i === 0 ? topCap : nestedCap, c.url)}`;
62
66
  })
63
67
  .join("\n\n");
64
68
  }
65
- export function blockquote(body) {
66
- return body
69
+ export function blockquote(body, maxChars, url) {
70
+ const text = maxChars === undefined ? body : truncateBody(body, maxChars, url);
71
+ return text
67
72
  .replace(/\r\n/g, "\n")
68
73
  .split("\n")
69
74
  .map((line) => (line === "" ? ">" : `> ${line}`))
70
75
  .join("\n");
71
76
  }
72
- function renderThreadCommentBullets(t) {
77
+ function renderThreadCommentBullets(t, verbose = false) {
78
+ const topCap = verbose ? undefined : BODY_TRUNCATE_MAX_CHARS;
79
+ const nestedCap = verbose ? undefined : NESTED_BODY_TRUNCATE_MAX_CHARS;
73
80
  return threadComments(t)
74
- .map((c) => {
81
+ .map((c, i) => {
75
82
  const link = c.url ? ` [↗](${c.url})` : "";
76
83
  const id = c.id ? `\`commentId=${c.id}\`` : "comment";
77
84
  return [
78
85
  ` - ${id}${link} (${renderAuthor(c.author, c.authorType, c.authorAssociation, c.viewerDidAuthor)})`,
79
- indentBlockquote(c.body, " "),
86
+ indentBlockquote(c.body, " ", i === 0 ? topCap : nestedCap, c.url),
80
87
  ].join("\n");
81
88
  })
82
89
  .join("\n");
83
90
  }
84
- function indentBlockquote(body, indent) {
85
- return blockquote(body)
91
+ function indentBlockquote(body, indent, maxChars, url) {
92
+ return blockquote(body, maxChars, url)
86
93
  .split("\n")
87
94
  .map((line) => `${indent}${line}`)
88
95
  .join("\n");
@@ -118,13 +125,14 @@ export function renderReviewListSection(heading, items) {
118
125
  * Threads that also appear in resolutionOnlyIds have their body suppressed
119
126
  * (already shown in `## Review threads to resolve`).
120
127
  */
121
- export function buildFirstLookBullets(firstLookThreads, resolutionOnlyIds, firstLookComments) {
128
+ export function buildFirstLookBullets(firstLookThreads, resolutionOnlyIds, firstLookComments, verbose = false) {
122
129
  const bullets = [];
123
130
  for (const t of firstLookThreads) {
124
131
  bullets.push(renderThreadBullet(t, {
125
132
  statusTag: renderFirstLookStatusTag(t),
126
133
  noBody: resolutionOnlyIds.has(t.id),
127
134
  suppressEditedMarker: true,
135
+ verbose,
128
136
  }));
129
137
  }
130
138
  for (const c of firstLookComments) {
@@ -1,6 +1,21 @@
1
1
  import { type AnnotationCacheOptions } from "../github/check-annotations.mts";
2
2
  import type { CheckAnnotation, ClassifiedCheck, ShepherdReport, TriagedCheck } from "../types.mts";
3
3
  export declare function checksWithActionableAnnotations(report: ShepherdReport): TriagedCheck[];
4
+ /**
5
+ * True when GitHub itself is already acting on this PR regardless of Shepherd (a failing
6
+ * check, an unseen check-run annotation, or a hard merge conflict) — the categories that
7
+ * `commands/iterate/index.mts` never defers while a PR is queued. Shared with `check.mts` so
8
+ * the seen-marker suppression gate there can't drift from the actual iterate dispatch
9
+ * decision (a queued PR with a failing check still renders review items via `fix_code`; their
10
+ * seen markers must not be suppressed just because the PR happens to be queued).
11
+ */
12
+ export declare function hasCheckDrivenActionableWork(checks: {
13
+ failing: TriagedCheck[];
14
+ passing: ClassifiedCheck[];
15
+ skipped: ClassifiedCheck[];
16
+ filtered: ClassifiedCheck[];
17
+ ignored?: TriagedCheck[];
18
+ }, mergeStatusValue: string): boolean;
4
19
  export declare function attachAndMergeCheckAnnotations(buckets: {
5
20
  passing: ClassifiedCheck[];
6
21
  failing: TriagedCheck[];
@@ -2,6 +2,9 @@ import { fetchCheckRunAnnotations, } from "../github/check-annotations.mjs";
2
2
  function shouldFetchCheckAnnotations(check) {
3
3
  return check.id != null && check.status === "COMPLETED" && check.hasAnnotations === true;
4
4
  }
5
+ function hasActionableAnnotation(check) {
6
+ return check.conclusion !== "SUCCESS" && (check.annotations?.length ?? 0) > 0;
7
+ }
5
8
  export function checksWithActionableAnnotations(report) {
6
9
  return [
7
10
  ...report.checks.failing,
@@ -9,7 +12,26 @@ export function checksWithActionableAnnotations(report) {
9
12
  ...report.checks.skipped,
10
13
  ...report.checks.filtered,
11
14
  ...(report.checks.ignored ?? []),
12
- ].filter((c) => c.conclusion !== "SUCCESS" && (c.annotations?.length ?? 0) > 0);
15
+ ].filter(hasActionableAnnotation);
16
+ }
17
+ /**
18
+ * True when GitHub itself is already acting on this PR regardless of Shepherd (a failing
19
+ * check, an unseen check-run annotation, or a hard merge conflict) — the categories that
20
+ * `commands/iterate/index.mts` never defers while a PR is queued. Shared with `check.mts` so
21
+ * the seen-marker suppression gate there can't drift from the actual iterate dispatch
22
+ * decision (a queued PR with a failing check still renders review items via `fix_code`; their
23
+ * seen markers must not be suppressed just because the PR happens to be queued).
24
+ */
25
+ export function hasCheckDrivenActionableWork(checks, mergeStatusValue) {
26
+ return (checks.failing.length > 0 ||
27
+ [
28
+ ...checks.failing,
29
+ ...checks.passing,
30
+ ...checks.skipped,
31
+ ...checks.filtered,
32
+ ...(checks.ignored ?? []),
33
+ ].some(hasActionableAnnotation) ||
34
+ mergeStatusValue === "CONFLICTS");
13
35
  }
14
36
  export async function attachAndMergeCheckAnnotations(buckets, seenMap, prNumber, cacheOpts) {
15
37
  const candidates = [
@@ -4,4 +4,5 @@ export declare function runCheck(opts: GlobalOptions & {
4
4
  autoMinimizeSuppressed?: boolean;
5
5
  skipTriage?: boolean;
6
6
  persistSeen?: boolean;
7
+ merge?: boolean;
7
8
  }): Promise<ShepherdReport>;
@@ -7,7 +7,7 @@ import { deriveMergeStatus } from "../merge-status/derive.mjs";
7
7
  import { loadConfig } from "../config/load.mjs";
8
8
  import { classifyVisibleComments } from "../comments/visible-comments.mjs";
9
9
  import { computeStatus } from "./check-status.mjs";
10
- import { annotationMarkerBody, attachAndMergeCheckAnnotations } from "./check-annotations.mjs";
10
+ import { annotationMarkerBody, attachAndMergeCheckAnnotations, hasCheckDrivenActionableWork, } from "./check-annotations.mjs";
11
11
  import { buildTerminalReport } from "./check-terminal-report.mjs";
12
12
  import { isBlockedByFilteredCheck, refreshReadyMergeability, refreshUnknownMergeability, } from "./ready-mergeability.mjs";
13
13
  import { loadSeenMap, markSeen, classifyItem } from "../state/seen-comments.mjs";
@@ -47,9 +47,16 @@ export async function runCheck(opts) {
47
47
  const allChecks = mergeStartupFailureChecks(batchData.checks, startupFailureChecks);
48
48
  const classifiedPrChecks = classifyChecks(allChecks);
49
49
  const latestRemoval = batchData.latestMergeQueueRemoval;
50
+ // `timelineItems(last: 1, ...)` returns the single most recent removal regardless of age, so
51
+ // a PR removed from the queue once, long ago, and never re-added keeps returning that same
52
+ // historical event forever. When GitHub omits the removed queue commit for that old event
53
+ // (e.g. after the synthetic commit is garbage collected), freshness is unverifiable — treat
54
+ // it as stale/updated rather than as still current, so Shepherd doesn't escalate
55
+ // `merge-queue-removed` permanently on data it can no longer check. The raw removal fields
56
+ // still render in the merge-queue header regardless of this flag.
50
57
  const headUpdatedAfterRemoval = Boolean(latestRemoval &&
51
- latestRemoval.beforeCommitParentOids &&
52
- !latestRemoval.beforeCommitParentOids.includes(batchData.headRefOid));
58
+ (!latestRemoval.beforeCommitParentOids ||
59
+ !latestRemoval.beforeCommitParentOids.includes(batchData.headRefOid)));
53
60
  const queueRawChecks = batchData.isInMergeQueue
54
61
  ? (batchData.mergeQueueChecks ?? [])
55
62
  : latestRemoval && !headUpdatedAfterRemoval
@@ -118,6 +125,51 @@ export async function runCheck(opts) {
118
125
  }
119
126
  const changesRequestedReviewVisibility = classifyChangesRequestedReviewsForDisplay(batchData.changesRequestedReviews.filter((r) => !partition.suppressedChangesRequestedIds.has(r.id)), seenMap, botUsernames, batchData.viewerAuthorization?.viewerCanAdminister === true);
120
127
  const approvedReviewVisibility = classifyReviewsForDisplay(batchData.approvedReviews, seenMap);
128
+ const changesRequestedReviews = changesRequestedReviewVisibility.visible;
129
+ const visibleChangesRequestedIds = new Set(changesRequestedReviews.map((review) => review.id));
130
+ const changesRequestedReviewCount = batchData.changesRequestedReviews.filter((review) => {
131
+ if (partition.suppressedChangesRequestedIds.has(review.id))
132
+ return false;
133
+ const isBot = !isHumanAuthor(review) || isConfiguredBotAuthor(review, botUsernames);
134
+ return (!isBot ||
135
+ batchData.viewerAuthorization?.viewerCanAdminister === true ||
136
+ visibleChangesRequestedIds.has(review.id));
137
+ }).length;
138
+ const approvedReviews = approvedReviewVisibility.visible;
139
+ let status = computeStatus(verdict, threadVisibility.activeThreads.length + threadVisibility.resolutionOnlyThreads.length, visibleCommentClassification.actionable.length, mergeStatus, changesRequestedReviewCount);
140
+ // Resolve any pending mergeability refresh (and the resulting MERGED/CLOSED short-circuit)
141
+ // before deciding what to persist below — deferWhileQueued must see the same final,
142
+ // possibly-refreshed mergeStatus that commands/iterate/index.mts acts on, not the pre-refresh
143
+ // snapshot. A conflict newly discovered by this REST read is exactly the kind of check-driven
144
+ // signal that keeps a queued PR out of the deferral path.
145
+ if (status === "READY" && !didRefreshMergeability) {
146
+ const refreshed = await refreshReadyMergeability(prNumber, repo, batchData, verdict, threadVisibility.activeThreads.length + threadVisibility.resolutionOnlyThreads.length, visibleCommentClassification.actionable.length, changesRequestedReviewCount);
147
+ batchData = refreshed.batchData;
148
+ mergeStatus = refreshed.mergeStatus;
149
+ status = refreshed.status;
150
+ if (mergeStatus.state === "MERGED" || mergeStatus.state === "CLOSED") {
151
+ return buildTerminalReport(prNumber, repo, batchData, mergeStatus, mergeStatus.state);
152
+ }
153
+ }
154
+ // Mirrors the deferral gate in commands/iterate/index.mts exactly (via the shared
155
+ // hasCheckDrivenActionableWork helper, so the two can't drift): when this tick's non-CI
156
+ // actionable work (threads/comments/review summaries/changes-requested) will be held back
157
+ // because the PR is queued, none of it was actually shown to the agent this tick —
158
+ // persisting seen markers for it now would make it silently vanish from every later tick
159
+ // once it's no longer "new" or "edited", even after the PR leaves the queue. A queued PR
160
+ // that also has check-driven work (failing checks, actionable annotations, conflicts) is
161
+ // NOT deferred — index.mts still renders these items via fix_code — so this must stay false
162
+ // in that case too, or their seen markers would be suppressed while actually being shown.
163
+ const deferWhileQueued = opts.merge === true &&
164
+ batchData.isInMergeQueue === true &&
165
+ config.actions.workWhileQueued !== true &&
166
+ !hasCheckDrivenActionableWork({
167
+ failing: merged.failing,
168
+ passing: merged.passing,
169
+ skipped: merged.skipped,
170
+ filtered: merged.filtered,
171
+ ignored: merged.ignored,
172
+ }, mergeStatus.status);
121
173
  if (opts.persistSeen !== false) {
122
174
  const successfulAnnotations = [
123
175
  ...merged.passing,
@@ -127,19 +179,24 @@ export async function runCheck(opts) {
127
179
  ]
128
180
  .filter((check) => check.conclusion === "SUCCESS")
129
181
  .flatMap((check) => check.annotations ?? []);
182
+ const deferredMarkSeen = deferWhileQueued
183
+ ? []
184
+ : [
185
+ ...firstLookComments.map((c) => markSeen(stateKey, c.id, c.body)),
186
+ ...threadVisibility.toMarkSeen.map((t) => markSeen(stateKey, t.id, threadTranscriptBody(t))),
187
+ ...visibleCommentClassification.toMarkSeen.map((c) => markSeen(stateKey, c.id, c.body)),
188
+ ...[...firstLookSummaries, ...editedSummaries].map((r) => markSeen(stateKey, r.id, r.body)),
189
+ ...changesRequestedReviewVisibility.toMarkSeen.map((r) => markSeen(stateKey, r.id, r.body)),
190
+ ...approvedReviewVisibility.toMarkSeen.map((r) => markSeen(stateKey, r.id, r.body)),
191
+ ];
130
192
  await Promise.allSettled([
131
193
  ...successfulAnnotations.map((a) => markSeen(stateKey, a.id, annotationMarkerBody(a))),
132
- ...firstLookComments.map((c) => markSeen(stateKey, c.id, c.body)),
133
- ...threadVisibility.toMarkSeen.map((t) => markSeen(stateKey, t.id, threadTranscriptBody(t))),
134
- ...visibleCommentClassification.toMarkSeen.map((c) => markSeen(stateKey, c.id, c.body)),
135
- ...[...firstLookSummaries, ...editedSummaries].map((r) => markSeen(stateKey, r.id, r.body)),
136
- ...changesRequestedReviewVisibility.toMarkSeen.map((r) => markSeen(stateKey, r.id, r.body)),
137
- ...approvedReviewVisibility.toMarkSeen.map((r) => markSeen(stateKey, r.id, r.body)),
194
+ ...deferredMarkSeen,
138
195
  ...batchData.comments
139
- .filter((c) => partition.suppressedCommentIds.has(c.id))
196
+ .filter((c) => partition.suppressedCommentIds.has(c.id) && !deniedRuleAutoResolveCommentIds.has(c.id))
140
197
  .map((c) => markSeen(stateKey, c.id, c.body)),
141
198
  ...batchData.reviewThreads
142
- .filter((t) => partition.suppressedThreadIds.has(t.id))
199
+ .filter((t) => partition.suppressedThreadIds.has(t.id) && !deniedRuleAutoResolveThreadIds.has(t.id))
143
200
  .map((t) => markSeen(stateKey, t.id, threadTranscriptBody(t))),
144
201
  ...batchData.reviewSummaries
145
202
  .filter((r) => partition.suppressedReviewSummaryIds.has(r.id) &&
@@ -163,27 +220,6 @@ export async function runCheck(opts) {
163
220
  ...authorizedRuleAutoResolveThreadIds,
164
221
  ...[...deniedRuleAutoResolveThreadIds].filter((id) => visibleMutationThreadIds.has(id)),
165
222
  ];
166
- const changesRequestedReviews = changesRequestedReviewVisibility.visible;
167
- const visibleChangesRequestedIds = new Set(changesRequestedReviews.map((review) => review.id));
168
- const changesRequestedReviewCount = batchData.changesRequestedReviews.filter((review) => {
169
- if (partition.suppressedChangesRequestedIds.has(review.id))
170
- return false;
171
- const isBot = !isHumanAuthor(review) || isConfiguredBotAuthor(review, botUsernames);
172
- return (!isBot ||
173
- batchData.viewerAuthorization?.viewerCanAdminister === true ||
174
- visibleChangesRequestedIds.has(review.id));
175
- }).length;
176
- const approvedReviews = approvedReviewVisibility.visible;
177
- let status = computeStatus(verdict, threadVisibility.activeThreads.length + threadVisibility.resolutionOnlyThreads.length, visibleCommentClassification.actionable.length, mergeStatus, changesRequestedReviewCount);
178
- if (status === "READY" && !didRefreshMergeability) {
179
- const refreshed = await refreshReadyMergeability(prNumber, repo, batchData, verdict, threadVisibility.activeThreads.length + threadVisibility.resolutionOnlyThreads.length, visibleCommentClassification.actionable.length, changesRequestedReviewCount);
180
- batchData = refreshed.batchData;
181
- mergeStatus = refreshed.mergeStatus;
182
- status = refreshed.status;
183
- if (mergeStatus.state === "MERGED" || mergeStatus.state === "CLOSED") {
184
- return buildTerminalReport(prNumber, repo, batchData, mergeStatus, mergeStatus.state);
185
- }
186
- }
187
223
  const blockedByFilteredCheck = isBlockedByFilteredCheck(mergeStatus, verdict);
188
224
  const queueCommit = batchData.isInMergeQueue
189
225
  ? batchData.mergeQueueEntry?.headCommitOid
@@ -90,12 +90,12 @@ export function buildFailingCheckInstructions(checks) {
90
90
  }
91
91
  export function buildFixCompletionInstruction(checks, hasConflicts = false, hasShaGatedReviewMutations = false) {
92
92
  if (hasConflicts)
93
- return "`[FIX_CODE]` is non-terminal: resolve the conflicts, commit, push to the PR head branch, then iterate again with the same options.";
93
+ return "`[FIX_CODE]` is non-terminal: resolve the conflicts, commit, push to the PR head branch, then iterate immediately with the same options.";
94
94
  if (hasShaGatedReviewMutations) {
95
- return "`[FIX_CODE]` is non-terminal: if you changed code, commit and push to the PR head branch, then run the review mutations using the pushed commit SHA and iterate again with the same options; if you did not change code, complete the authorized review mutations and iterate again with the same options.";
95
+ return "`[FIX_CODE]` is non-terminal: if you changed code, commit and push to the PR head branch, then run the review mutations using the pushed commit SHA and iterate immediately with the same options; if you did not change code, complete the authorized review mutations and iterate immediately with the same options.";
96
96
  }
97
97
  if (checks.some((check) => check.rerunCommand)) {
98
- return "`[FIX_CODE]` is non-terminal. Run any warranted reruns for `[rerun authorized]` checks (or apply code fixes for real failures), then iterate again with the same options to continue.";
98
+ return "`[FIX_CODE]` is non-terminal. Run any warranted reruns for `[rerun authorized]` checks (or apply code fixes for real failures), then iterate immediately with the same options to continue.";
99
99
  }
100
- return "`[FIX_CODE]` is non-terminal. After completing these steps, iterate again with the same options to continue.";
100
+ return "`[FIX_CODE]` is non-terminal. After completing these steps, iterate immediately with the same options to continue.";
101
101
  }
@@ -1,3 +1,4 @@
1
+ /* eslint-disable max-lines */
1
2
  import { runCheck } from "../check.mjs";
2
3
  import { updateReadyDelay } from "../ready-delay.mjs";
3
4
  import { getCurrentPrNumber } from "../../github/client.mjs";
@@ -10,7 +11,7 @@ import { clearStallState } from "../../state/iterate-stall.mjs";
10
11
  import { handleFixCode } from "./fix-code.mjs";
11
12
  import { normalizeBotUsernames } from "../../comments/authors.mjs";
12
13
  import { autoMinimizeComments } from "../../comments/resolve.mjs";
13
- import { checksWithActionableAnnotations } from "../check-annotations.mjs";
14
+ import { hasCheckDrivenActionableWork } from "../check-annotations.mjs";
14
15
  import { buildReadyMergeResult, handleActiveMergeState } from "./merge-state.mjs";
15
16
  import { buildIterateBase } from "./base.mjs";
16
17
  import { markReadyIfAuthorized } from "./mark-ready.mjs";
@@ -73,9 +74,7 @@ async function runIterateCore(opts) {
73
74
  (report.comments.minimizeIds?.length ?? 0) > 0 ||
74
75
  report.comments.firstLook.length > 0 ||
75
76
  report.changesRequestedReviews.length > 0 ||
76
- report.checks.failing.length > 0 ||
77
- checksWithActionableAnnotations(report).length > 0 ||
78
- report.mergeStatus.status === "CONFLICTS" ||
77
+ hasCheckDrivenActionableWork(report.checks, report.mergeStatus.status) ||
79
78
  reviewSummaryIds.length > 0 ||
80
79
  firstLookSummaries.length > 0 ||
81
80
  editedSummaries.length > 0 ||
@@ -85,7 +84,16 @@ async function runIterateCore(opts) {
85
84
  const readyState = await updateReadyDelay(report.pr, isCleanReadyState, readyDelaySeconds, repoOwner, repoName);
86
85
  const base = buildIterateBase(report, readyState);
87
86
  const headSha = (await getCurrentHeadSha()) ?? "unknown";
88
- if (hasActionableWork) {
87
+ // Checks (including merge-queue synthetic-commit checks) and hard conflicts are signals
88
+ // GitHub itself is already acting on — the queue will eject the PR for these regardless of
89
+ // what Shepherd does, so they always surface immediately. Only review threads/comments/
90
+ // changes-requested reviews/review summaries — the categories that would otherwise cause a
91
+ // Shepherd-initiated push while the PR sits safely in the queue — are eligible for deferral.
92
+ const checkDrivenActionableWork = hasCheckDrivenActionableWork(report.checks, report.mergeStatus.status);
93
+ const deferWhileQueued = opts.merge === true &&
94
+ report.mergeQueue?.inQueue === true &&
95
+ config.actions.workWhileQueued !== true;
96
+ if (hasActionableWork && !(deferWhileQueued && !checkDrivenActionableWork)) {
89
97
  return handleFixCode({
90
98
  base,
91
99
  report,
@@ -110,6 +118,11 @@ async function runIterateCore(opts) {
110
118
  base,
111
119
  report,
112
120
  stallKey,
121
+ reviewSummaryIds,
122
+ firstLookSummaries,
123
+ editedSummaries,
124
+ surfacedApprovals,
125
+ minimizeApprovals: config.iterate.minimizeApprovals,
113
126
  });
114
127
  if (mergeStateResult)
115
128
  return mergeStateResult;
@@ -1,4 +1,4 @@
1
- import type { IterateResult, IterateResultBase, ShepherdReport } from "../../types.mts";
1
+ import type { IterateResult, IterateResultBase, Review, ShepherdReport } from "../../types.mts";
2
2
  type StallKey = {
3
3
  owner: string;
4
4
  repo: string;
@@ -11,5 +11,10 @@ export declare function handleActiveMergeState(input: {
11
11
  base: IterateResultBase;
12
12
  report: ShepherdReport;
13
13
  stallKey: StallKey;
14
+ reviewSummaryIds: string[];
15
+ firstLookSummaries: Review[];
16
+ editedSummaries: Review[];
17
+ surfacedApprovals: Review[];
18
+ minimizeApprovals: boolean;
14
19
  }): Promise<IterateResult | null>;
15
20
  export {};
@@ -19,14 +19,55 @@ export function buildReadyMergeResult(enabled, readyElapsed, base, report) {
19
19
  }),
20
20
  };
21
21
  }
22
+ /** Raw counts of non-CI actionable work held back for one queued-PR wait tick. Omitted (all zero) when empty. */
23
+ function buildDeferredWork(input) {
24
+ const { report, reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, minimizeApprovals, } = input;
25
+ // These buckets are not disjoint (e.g. an unresolved outdated thread is both
26
+ // `resolutionOnly` and `firstLook`; an eligible-to-minimize comment/summary is both
27
+ // `actionable`/`firstLook` and queued in `minimizeIds`/`reviewSummaryIds`) — dedupe by ID.
28
+ const threadIds = new Set([
29
+ ...report.threads.actionable.map((t) => t.id),
30
+ ...report.threads.resolutionOnly.map((t) => t.id),
31
+ ...report.threads.firstLook.map((t) => t.id),
32
+ ...(report.threads.ruleAutoResolveIds ?? []),
33
+ ]);
34
+ const commentIds = new Set([
35
+ ...report.comments.actionable.map((c) => c.id),
36
+ ...(report.comments.minimizeIds ?? []),
37
+ ...report.comments.firstLook.map((c) => c.id),
38
+ ]);
39
+ const reviewSummaryIdSet = new Set([
40
+ ...reviewSummaryIds,
41
+ ...firstLookSummaries.map((r) => r.id),
42
+ ...editedSummaries.map((r) => r.id),
43
+ ...(minimizeApprovals ? surfacedApprovals.map((r) => r.id) : []),
44
+ ]);
45
+ const deferredWork = {
46
+ threads: threadIds.size,
47
+ comments: commentIds.size,
48
+ changesRequestedReviews: report.changesRequestedReviews.length,
49
+ reviewSummaries: reviewSummaryIdSet.size,
50
+ };
51
+ const total = deferredWork.threads +
52
+ deferredWork.comments +
53
+ deferredWork.changesRequestedReviews +
54
+ deferredWork.reviewSummaries;
55
+ return total > 0 ? deferredWork : undefined;
56
+ }
22
57
  export async function handleActiveMergeState(input) {
23
58
  const { enabled, active, base, report, stallKey } = input;
59
+ const inQueue = report.mergeQueue?.inQueue === true;
24
60
  if (enabled && active) {
25
61
  await clearStallState(stallKey);
62
+ // Only the queued case ever holds back non-CI actionable work (see the `deferWhileQueued`
63
+ // gate in index.mts, which requires `inQueue === true`); an ordinary active auto-merge
64
+ // request with no queue membership never defers anything, so it never carries counts here.
65
+ const deferredWork = inQueue ? buildDeferredWork(input) : undefined;
26
66
  return {
27
67
  ...base,
28
68
  action: "wait",
29
- log: report.mergeQueue?.inQueue
69
+ ...(deferredWork && { deferredWork }),
70
+ log: inQueue
30
71
  ? `WAIT: PR #${report.pr} is in the merge queue`
31
72
  : `WAIT: PR #${report.pr} has auto-merge enabled`,
32
73
  };
@@ -96,10 +96,10 @@ isBehind = false, viewerCanUpdate = false, hasExhaustedWorkflowRerun = false) {
96
96
  instructions.push(`Commit any remaining conflict-resolution changes and push to the PR head branch${mutationSuffix}.`);
97
97
  }
98
98
  else if (hasRepeatedWorkflowBranchRecovery) {
99
- instructions.push("Push the updated PR head branch before iterating again.");
99
+ instructions.push("Push the updated PR head branch before iterating immediately.");
100
100
  }
101
101
  else if (hasNonConflictHints) {
102
- instructions.push("If you changed code, commit any remaining changes and push to the PR head branch, then run the remaining review mutations using the pushed commit SHA and iterate again with the same options. If you did not change code, do not commit and continue with the remaining steps.");
102
+ instructions.push("If you changed code, commit any remaining changes and push to the PR head branch, then run the remaining review mutations using the pushed commit SHA and iterate immediately with the same options. If you did not change code, do not commit and continue with the remaining steps.");
103
103
  }
104
104
  if (viewerCanUpdate &&
105
105
  (hasReviewMutations ||
@@ -44,6 +44,8 @@ export interface PrShepherdConfig {
44
44
  };
45
45
  checks: {
46
46
  ciTriggerEvents: string[];
47
+ /** Regex patterns matched against each raw log line; matching lines are dropped from log excerpts. Empty by default — no lines are stripped unless configured. */
48
+ ignoreLogLines: string[];
47
49
  };
48
50
  mergeStatus: {
49
51
  blockingReviewerLogins: string[];
@@ -57,6 +59,14 @@ export interface PrShepherdConfig {
57
59
  autoMarkReady: boolean;
58
60
  /** Legacy-named patterns that keep matching Actions checks visible despite ignoreChecks. */
59
61
  neverCancelRuns: string[];
62
+ /**
63
+ * When `false` (default), `iterate --merge` defers non-CI actionable work
64
+ * (review threads, comments, changes-requested reviews, review summaries)
65
+ * while the PR sits in the merge queue, since a Shepherd-initiated push
66
+ * would eject it. When `true`, restores pre-existing behavior: actionable
67
+ * work is handled immediately regardless of queue membership.
68
+ */
69
+ workWhileQueued: boolean;
60
70
  /** @deprecated Accepted for compatibility, ignored by the loader. */
61
71
  autoResolveOutdated?: boolean;
62
72
  /** @deprecated Accepted for compatibility, ignored by the loader. */
@@ -86,6 +86,20 @@ function parseNeverCancelRuns(value) {
86
86
  }
87
87
  return value;
88
88
  }
89
+ function parseIgnoreLogLines(value) {
90
+ if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
91
+ throw new Error(`Invalid config: checks.ignoreLogLines must be an array of strings`);
92
+ }
93
+ for (const pattern of value) {
94
+ try {
95
+ new RegExp(pattern);
96
+ }
97
+ catch {
98
+ throw new Error(`Invalid config: checks.ignoreLogLines contains an invalid regular expression: ${pattern}`);
99
+ }
100
+ }
101
+ return value;
102
+ }
89
103
  const SHEPHERD_OWNED_MERGE_FLAGS = [
90
104
  "--repo",
91
105
  "-R",
@@ -168,13 +182,14 @@ const KNOWN_NESTED_KEYS = {
168
182
  ]),
169
183
  watch: new Set(["readyDelayMinutes", "graphqlQuotaWarnings"]),
170
184
  resolve: new Set(["shaPoll"]),
171
- checks: new Set(["ciTriggerEvents"]),
185
+ checks: new Set(["ciTriggerEvents", "ignoreLogLines"]),
172
186
  mergeStatus: new Set(["blockingReviewerLogins"]),
173
187
  merge: new Set(["commandArgs"]),
174
188
  actions: new Set([
175
189
  "autoMinimizeSuppressed",
176
190
  "autoMarkReady",
177
191
  "neverCancelRuns",
192
+ "workWhileQueued",
178
193
  "autoResolveOutdated",
179
194
  "commitSuggestions",
180
195
  ]),
@@ -261,6 +276,7 @@ export function loadConfig() {
261
276
  config.merge.commandArgs = parseMergeCommandArgs(config.merge.commandArgs);
262
277
  config.watch.graphqlQuotaWarnings = parseGraphqlQuotaWarnings(config.watch.graphqlQuotaWarnings);
263
278
  config.iterate.minimizeComments = parseMinimizeCommentsPolicy(config.iterate.minimizeComments);
279
+ config.checks.ignoreLogLines = parseIgnoreLogLines(config.checks.ignoreLogLines);
264
280
  configCache.set(cwd, config);
265
281
  return config;
266
282
  }
package/bin/config.json CHANGED
@@ -36,7 +36,8 @@
36
36
  }
37
37
  },
38
38
  "checks": {
39
- "ciTriggerEvents": ["pull_request", "pull_request_target"]
39
+ "ciTriggerEvents": ["pull_request", "pull_request_target"],
40
+ "ignoreLogLines": []
40
41
  },
41
42
  "mergeStatus": {
42
43
  "blockingReviewerLogins": ["copilot"]
@@ -47,6 +48,7 @@
47
48
  "actions": {
48
49
  "autoMinimizeSuppressed": true,
49
50
  "autoMarkReady": true,
50
- "neverCancelRuns": []
51
+ "neverCancelRuns": [],
52
+ "workWhileQueued": false
51
53
  }
52
54
  }
@@ -5,7 +5,7 @@ import { z } from "zod";
5
5
  import { createPrShepherd, PartialApplyError, PrShepherdValidationError, } from "../api.mjs";
6
6
  import { isRepositoryQualifiedPrReference } from "../pr-reference.mjs";
7
7
  import { formatJournalResult } from "../cli/journal-formatter.mjs";
8
- import { formatCommitSuggestionResult, formatSuggestionPatchesResult, formatIterateResult, formatMarkFilesAsViewedResult, formatMutateResult, } from "../cli/formatters.mjs";
8
+ import { formatCommitSuggestionResult, formatSuggestionPatchesResult, formatIterateResult, formatMarkFilesAsViewedResult, formatMutateResult, projectIterateLean, } from "../cli/formatters.mjs";
9
9
  import { formatCliError, serializeGitHubRequestErrorDetails } from "../cli/error-format.mjs";
10
10
  import { errorToExitCode, EXIT } from "../exit-codes.mjs";
11
11
  const QUALIFIED_PR_ERROR = "pr must be a GitHub pull-request URL or an owner/repo#number reference";
@@ -88,7 +88,13 @@ export function createPrShepherdMcpServer(options = {}) {
88
88
  idempotentHint: false,
89
89
  openWorldHint: true,
90
90
  },
91
- }, async (input) => runTool(() => shepherd.iterate(requireRepositoryQualifiedPr(input)), formatIterateResult));
91
+ }, async (input) => {
92
+ // One options object feeds both channels so JSON and Markdown cannot drift.
93
+ const opts = {
94
+ readyDelaySuffix: input.readyDelaySeconds === undefined ? undefined : `${input.readyDelaySeconds}s`,
95
+ };
96
+ return runTool(() => shepherd.iterate(requireRepositoryQualifiedPr(input)), (result) => formatIterateResult(result, opts), (result) => projectIterateLean(result, opts));
97
+ });
92
98
  server.registerTool("apply", {
93
99
  description: "Apply ordered review, journal, and file-view operations after prevalidation; explicit requests rely on GitHub's mutation response.",
94
100
  inputSchema: applyInputSchema,
@@ -131,16 +137,22 @@ function readPackageVersion() {
131
137
  const packageJson = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
132
138
  return packageJson.version;
133
139
  }
134
- function toolResult(result, text) {
140
+ function toolResult(structured, text) {
135
141
  return {
136
142
  content: [{ type: "text", text }],
137
- structuredContent: result,
143
+ structuredContent: structured,
138
144
  };
139
145
  }
140
- async function runTool(work, format) {
146
+ /**
147
+ * `project` mirrors the CLI's `--format=json` treatment of the same result. Tools
148
+ * whose CLI JSON is the raw result object (apply, build_suggestion_patch(es) — see
149
+ * handlers.mts and cli-parser.mts, which JSON.stringify the result directly) omit
150
+ * `project` and return the result unchanged, matching their own CLI JSON output.
151
+ */
152
+ async function runTool(work, format, project) {
141
153
  try {
142
154
  const result = await work();
143
- return toolResult(result, format(result));
155
+ return toolResult(project ? project(result) : result, format(result));
144
156
  }
145
157
  catch (error) {
146
158
  return toolError(error);
@@ -2,5 +2,5 @@ export function buildQuotaAwareContinuation(warning, prefix) {
2
2
  const interval = `${warning.pollIntervalMinutes}m`;
3
3
  const timeout = `${warning.pollTimeoutMinutes}m`;
4
4
  const resetTime = new Date(warning.resetAt * 1000).toISOString();
5
- return `${prefix} GitHub's GraphQL API quota is low (crossed the ${warning.thresholdPercent}% remaining threshold). For the time being, prefer non-GraphQL \`gh\` CLI commands (e.g. \`gh pr view\`, \`gh pr checks\`, \`gh pr review\`, \`gh api\` REST endpoints) for PR operations — they draw on the separate REST budget, not the depleted GraphQL pool. Resume pr-shepherd after the GraphQL quota resets at ${resetTime}. If you must keep polling before then, poll no more often than every ${warning.pollIntervalMinutes} minutes. With a polling CLI command, preserve the other options, replace any existing interval and timeout flags with \`--interval ${interval} --timeout ${timeout}\`, and omit \`--timeout\` when using \`--until-terminal\`. With a single-tick CLI, API, or MCP call, wait at least ${warning.pollIntervalMinutes} minutes before the next tick.`;
5
+ return `${prefix} GitHub's GraphQL API quota is low (crossed the ${warning.thresholdPercent}% remaining threshold). Keep using pr-shepherd at the cadence below; for incidental PR operations that do not need Shepherd's full snapshot, prefer non-GraphQL \`gh\` CLI commands (e.g. \`gh pr view\`, \`gh pr review\`, \`gh api\` REST endpoints) — they draw on the separate REST budget, not the depleted GraphQL pool. Do not substitute \`gh pr checks\` or \`gh pr watch\` for the Shepherd loop. Resume full-cadence pr-shepherd after the GraphQL quota resets at ${resetTime}. If you must keep polling before then, poll no more often than every ${warning.pollIntervalMinutes} minutes. With a polling CLI command, preserve the other options, replace any existing interval and timeout flags with \`--interval ${interval} --timeout ${timeout}\`, and omit \`--timeout\` when using \`--until-terminal\`. With a single-tick CLI, API, or MCP call, wait at least ${warning.pollIntervalMinutes} minutes before the next tick.`;
6
6
  }
@@ -48,6 +48,7 @@ export interface IterateResultBase {
48
48
  interface IterateResultWait extends IterateResultBase {
49
49
  action: "wait";
50
50
  log: string;
51
+ deferredWork?: import("./merge-queue.mts").IterateDeferredWork;
51
52
  }
52
53
  export type CancelReason = "merged" | "closed" | "ready-delay-elapsed";
53
54
  interface IterateResultCancel extends IterateResultBase {
@@ -135,9 +136,18 @@ export interface IterateCommandOptions extends GlobalOptions {
135
136
  stallTimeoutSeconds?: number;
136
137
  /** Legacy per-invocation no-op retained for API compatibility. */
137
138
  neverCancelRuns?: string[];
139
+ /**
140
+ * Internal. `false` skips seen-marker writes (poll's discarded debounce ticks). Set
141
+ * only by `runPollCore`; excluded from the public `IterateInput` in api.mts.
142
+ */
138
143
  persistSeen?: boolean;
139
144
  /** Shepherd through readiness and emit the exact merge/queue command when ready. */
140
145
  merge?: boolean;
146
+ /**
147
+ * Internal. Defers attaching a quota warning until an until-terminal poll actually
148
+ * breaks. Set only by `runPollCore`; excluded from the public `IterateInput` in
149
+ * api.mts.
150
+ */
141
151
  deferQuotaWarning?: boolean;
142
152
  }
143
153
  export {};