pr-shepherd 0.59.3 → 0.60.1

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 (65) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/bin/api.d.mts +1 -1
  3. package/bin/classify/standard/codecov-report.d.mts +1 -1
  4. package/bin/classify/standard/codecov-report.mjs +7 -2
  5. package/bin/classify/standard/coderabbit-notices.d.mts +4 -3
  6. package/bin/classify/standard/coderabbit-notices.mjs +29 -47
  7. package/bin/classify/standard/codex-notices.mjs +34 -7
  8. package/bin/classify/standard/dependabot-notices.mjs +5 -1
  9. package/bin/classify/standard/gemini-notices.d.mts +1 -1
  10. package/bin/classify/standard/gemini-notices.mjs +16 -5
  11. package/bin/classify/standard/kilo-no-issues.d.mts +1 -1
  12. package/bin/classify/standard/kilo-no-issues.mjs +37 -10
  13. package/bin/classify/standard/sourcery-notices.mjs +1 -1
  14. package/bin/cli/edited-comments-format.d.mts +14 -0
  15. package/bin/cli/edited-comments-format.mjs +39 -0
  16. package/bin/cli/fix-formatter.mjs +5 -2
  17. package/bin/cli/iterate-activity-formatter.d.mts +1 -1
  18. package/bin/cli/iterate-activity-formatter.mjs +4 -5
  19. package/bin/cli/iterate-check-names.d.mts +5 -0
  20. package/bin/cli/iterate-check-names.mjs +17 -0
  21. package/bin/cli/iterate-formatter.mjs +15 -19
  22. package/bin/cli/iterate-lean.mjs +19 -11
  23. package/bin/cli/iterate-verbose.mjs +7 -2
  24. package/bin/cli/list-formatters.d.mts +0 -3
  25. package/bin/cli/list-formatters.mjs +1 -5
  26. package/bin/commands/check-review-budget.d.mts +39 -0
  27. package/bin/commands/check-review-budget.mjs +78 -0
  28. package/bin/commands/check-wait-detectors.d.mts +6 -1
  29. package/bin/commands/check-wait-detectors.mjs +2 -1
  30. package/bin/commands/check.d.mts +2 -0
  31. package/bin/commands/check.mjs +46 -25
  32. package/bin/commands/iterate/check-instructions.d.mts +5 -0
  33. package/bin/commands/iterate/check-instructions.mjs +4 -0
  34. package/bin/commands/iterate/classify.d.mts +1 -1
  35. package/bin/commands/iterate/edited-comment-markers.d.mts +3 -0
  36. package/bin/commands/iterate/edited-comment-markers.mjs +10 -0
  37. package/bin/commands/iterate/fix-code.mjs +17 -1
  38. package/bin/commands/iterate/helpers.mjs +9 -5
  39. package/bin/commands/iterate/index.mjs +44 -17
  40. package/bin/commands/iterate/render.mjs +1 -4
  41. package/bin/commands/poll-rate-limit-cancel.mjs +1 -1
  42. package/bin/commands/poll-seen-options.d.mts +8 -0
  43. package/bin/commands/poll-seen-options.mjs +7 -0
  44. package/bin/commands/poll.mjs +7 -5
  45. package/bin/comments/review-output-budget.d.mts +32 -0
  46. package/bin/comments/review-output-budget.mjs +55 -0
  47. package/bin/comments/visible-comments.d.mts +3 -1
  48. package/bin/comments/visible-comments.mjs +10 -5
  49. package/bin/config/load.d.mts +6 -0
  50. package/bin/config/load.mjs +7 -0
  51. package/bin/config.json +2 -1
  52. package/bin/github/poll-summary-fingerprint.mjs +5 -1
  53. package/bin/github/poll-summary-review.mjs +2 -1
  54. package/bin/reporters/agent.d.mts +1 -3
  55. package/bin/reporters/agent.mjs +0 -1
  56. package/bin/types/escalate.d.mts +9 -0
  57. package/bin/types/iterate.d.mts +19 -1
  58. package/bin/types/report.d.mts +23 -4
  59. package/bin/util/cap-list.d.mts +7 -0
  60. package/bin/util/cap-list.mjs +13 -0
  61. package/package.json +2 -2
  62. package/plugins/pr-shepherd/.claude-plugin/plugin.json +1 -1
  63. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  64. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  65. package/plugins/pr-shepherd/.mcp.json +1 -1
@@ -1,8 +1,10 @@
1
1
  import { adaptIterateLog, buildSimpleIterateInstructions } from "./iterate-instructions.mjs";
2
2
  import { projectRuleAutoResolve } from "../commands/rule-auto-resolve-format.mjs";
3
+ import { projectEditedComments } from "./edited-comments-format.mjs";
3
4
  import { projectMergeRequirements } from "../merge-status/requirements-format.mjs";
4
5
  import { branchSegmentShowsBase } from "./iterate-branch-segment.mjs";
5
6
  import { isRedundantMergeQueue } from "./iterate-merge-formatter.mjs";
7
+ import { capList } from "../util/cap-list.mjs";
6
8
  /**
7
9
  * Project an IterateResult to a lean JSON shape for the default (non-verbose) output.
8
10
  * Omits fields that are the trivial default (false, 0, empty) or state-gated fields
@@ -31,7 +33,7 @@ export function projectIterateLean(result, opts) {
31
33
  }),
32
34
  };
33
35
  }
34
- const counts = Object.fromEntries(["passing", "skipped", "filtered", "inProgress", "superseded"]
36
+ const counts = Object.fromEntries(["passing", "skipped", "filtered", "inProgress", "queued", "superseded"]
35
37
  .filter((k) => result.summary[k] > 0)
36
38
  .map((k) => [k, result.summary[k]]));
37
39
  const summary = Object.keys(counts).length > 0 ? counts : null;
@@ -82,15 +84,9 @@ export function projectIterateLean(result, opts) {
82
84
  }),
83
85
  },
84
86
  }),
85
- ...((result.inProgressChecks?.length ?? 0) > 0 && {
86
- inProgressChecks: result.inProgressChecks,
87
- }),
88
- ...((result.ignoredNames?.length ?? 0) > 0 && {
89
- ignoredNames: result.ignoredNames,
90
- }),
91
- ...((result.supersededNames?.length ?? 0) > 0 && {
92
- supersededNames: result.supersededNames,
93
- }),
87
+ ...cappedList("inProgressChecks", result.inProgressChecks),
88
+ ...cappedList("ignoredNames", result.ignoredNames),
89
+ ...cappedList("supersededNames", result.supersededNames),
94
90
  ...((result.unreportedRequiredChecks?.length ?? 0) > 0 && {
95
91
  unreportedRequiredChecks: result.unreportedRequiredChecks,
96
92
  }),
@@ -104,6 +100,9 @@ export function projectIterateLean(result, opts) {
104
100
  ...(result.ruleAutoResolve && {
105
101
  ruleAutoResolve: projectRuleAutoResolve(result.ruleAutoResolve),
106
102
  }),
103
+ ...(result.editedComments?.length && {
104
+ editedComments: projectEditedComments(result.editedComments),
105
+ }),
107
106
  };
108
107
  switch (result.action) {
109
108
  case "ready":
@@ -144,7 +143,7 @@ export function projectIterateLean(result, opts) {
144
143
  case "fix_code":
145
144
  return {
146
145
  ...base,
147
- ...(result.checks.length > 0 && { checks: result.checks }),
146
+ // Passing checks stay summary counts and failures stay in `fix.checks`, as in lean text.
148
147
  ...(result.cancelled.length > 0 && { cancelled: result.cancelled }),
149
148
  fix: {
150
149
  ...(result.fix.threads.length > 0 && { threads: result.fix.threads }),
@@ -190,6 +189,7 @@ export function projectIterateLean(result, opts) {
190
189
  ...(result.fix.resolveOnlyCommand && {
191
190
  resolveOnlyCommand: result.fix.resolveOnlyCommand,
192
191
  }),
192
+ ...(result.fix.reviewItems && { reviewItems: result.fix.reviewItems }),
193
193
  ...(result.fix.instructions.length > 0 && {
194
194
  instructions: result.fix.instructions,
195
195
  }),
@@ -229,6 +229,7 @@ export function projectIterateLean(result, opts) {
229
229
  result.escalate.thrashHistory.length > 0 && {
230
230
  thrashHistory: result.escalate.thrashHistory,
231
231
  }),
232
+ ...(result.escalate.reviewItems && { reviewItems: result.escalate.reviewItems }),
232
233
  ...(result.escalate.mergeQueueRemoval && {
233
234
  mergeQueueRemoval: result.escalate.mergeQueueRemoval,
234
235
  }),
@@ -246,4 +247,11 @@ export function projectIterateLean(result, opts) {
246
247
  };
247
248
  }
248
249
  }
250
+ /** `{ [key]: first 5, [key + "More"]: hidden count }`, matching the capped Markdown lists. */
251
+ function cappedList(key, items) {
252
+ if (!items || items.length === 0)
253
+ return {};
254
+ const { shown, hidden } = capList(items);
255
+ return { [key]: shown, ...(hidden > 0 && { [`${key}More`]: hidden }) };
256
+ }
249
257
  export { projectIterateVerbose } from "./iterate-verbose.mjs";
@@ -1,11 +1,16 @@
1
1
  import { adaptIterateLog, buildSimpleIterateInstructions } from "./iterate-instructions.mjs";
2
+ import { projectEditedComments } from "./edited-comments-format.mjs";
2
3
  export function projectIterateVerbose(result, opts) {
3
4
  const readyDelaySuffix = opts?.readyDelaySuffix;
4
5
  const readyDelayOverride = readyDelaySuffix ? { readyDelayOverride: readyDelaySuffix } : {};
6
+ // Edited comments use the lean projection: their bookkeeping fields have no Markdown form.
7
+ const edited = result.editedComments && {
8
+ editedComments: projectEditedComments(result.editedComments),
9
+ };
5
10
  if (result.action === "fix_code")
6
- return { ...result, ...readyDelayOverride };
11
+ return { ...result, ...readyDelayOverride, ...edited };
7
12
  const log = "log" in result && typeof result.log === "string" ? { log: adaptIterateLog(result.log) } : {};
8
13
  // `cancel` has no instructions, so verbose JSON omits the field as verbose Markdown omits the section.
9
14
  const instructions = result.action === "cancel" ? {} : { instructions: buildSimpleIterateInstructions(result) };
10
- return { ...result, ...log, ...readyDelayOverride, ...instructions };
15
+ return { ...result, ...log, ...readyDelayOverride, ...instructions, ...edited };
11
16
  }
@@ -49,9 +49,6 @@ export declare function renderCommentBullet(c: {
49
49
  }, opts?: {
50
50
  statusTag?: string;
51
51
  }): string;
52
- export declare function renderEditedCommentTag(c: {
53
- edited?: boolean;
54
- }): string | undefined;
55
52
  export declare function renderReviewBullet(r: {
56
53
  id: string;
57
54
  author: string;
@@ -101,9 +101,6 @@ export function renderCommentBullet(c, opts = {}) {
101
101
  const statusSuffix = opts.statusTag ? ` ${opts.statusTag}` : "";
102
102
  return `- \`commentId=${c.id}\`${link} (${renderAuthor(c.author, c.authorType, c.authorAssociation)})${statusSuffix}: ${renderBodyPreview(c.body)}`;
103
103
  }
104
- export function renderEditedCommentTag(c) {
105
- return c.edited ? "[edited since first look]" : undefined;
106
- }
107
104
  export function renderReviewBullet(r, opts = {}) {
108
105
  const base = `- \`reviewId=${r.id}\` (${renderAuthor(r.author, r.authorType, r.authorAssociation)})`;
109
106
  const staleTag = r.staleReview
@@ -138,8 +135,7 @@ export function buildFirstLookBullets(firstLookThreads, resolutionOnlyIds, first
138
135
  }));
139
136
  }
140
137
  for (const c of firstLookComments) {
141
- const editedSuffix = c.edited ? ", edited" : "";
142
- bullets.push(renderCommentBullet(c, { statusTag: `[status: minimized${editedSuffix}]` }));
138
+ bullets.push(renderCommentBullet(c, { statusTag: "[status: minimized]" }));
143
139
  }
144
140
  return bullets;
145
141
  }
@@ -0,0 +1,39 @@
1
+ import { type ReviewItemRollup } from "../comments/review-output-budget.mts";
2
+ import type { ActionableComment, EditedComment, FirstLookComment, FirstLookThread, Review, ReviewThread } from "../types.mts";
3
+ /** Every check.mts list that renders a review item, generates a mutation for it, or marks it seen. */
4
+ export interface ReviewOutputBuckets {
5
+ changesRequested: {
6
+ visible: Review[];
7
+ toMarkSeen: Review[];
8
+ };
9
+ threads: {
10
+ activeThreads: ReviewThread[];
11
+ resolutionOnlyThreads: ReviewThread[];
12
+ firstLookThreads: FirstLookThread[];
13
+ toMarkSeen: ReviewThread[];
14
+ };
15
+ comments: {
16
+ actionable: ActionableComment[];
17
+ minimizeIds: string[];
18
+ toMarkSeen: ActionableComment[];
19
+ };
20
+ firstLookComments: FirstLookComment[];
21
+ firstLookSummaries: Review[];
22
+ editedSummaries: Review[];
23
+ editedComments: EditedComment[];
24
+ }
25
+ /**
26
+ * Applies `iterate.reviewOutputMaxChars` to one tick's review buckets. Priority: changes-requested
27
+ * reviews, active threads and comments, first-look items, review summaries, edited comments.
28
+ * Approved reviews are opt-in minimize targets, not review work, and are never held.
29
+ * Held items leave every list — the report, the generated mutation IDs, and the seen-marker
30
+ * writes — so the next tick shows them. `rollup` is set only when something was held.
31
+ * Edited comments are held only alongside other held work: edits never block readiness, so a
32
+ * ready or terminal tick must not leave some of them unshown. `heldThreadReviewIds` names the
33
+ * parent reviews of held unresolved threads, which must not be minimized while those wait.
34
+ */
35
+ export declare function applyReviewOutputBudget(buckets: ReviewOutputBuckets, maxChars: number): {
36
+ buckets: ReviewOutputBuckets;
37
+ rollup?: ReviewItemRollup;
38
+ heldThreadReviewIds?: string[];
39
+ };
@@ -0,0 +1,78 @@
1
+ import { selectReviewOutput, } from "../comments/review-output-budget.mjs";
2
+ import { threadTranscriptBody } from "../threads/transcript.mjs";
3
+ const bodyItem = (item) => ({
4
+ id: item.id,
5
+ cost: item.body.length,
6
+ ...(item.createdAtUnix !== undefined && { createdAtUnix: item.createdAtUnix }),
7
+ });
8
+ const threadItem = (thread) => ({
9
+ ...bodyItem(thread),
10
+ cost: threadTranscriptBody(thread).length,
11
+ });
12
+ /**
13
+ * Applies `iterate.reviewOutputMaxChars` to one tick's review buckets. Priority: changes-requested
14
+ * reviews, active threads and comments, first-look items, review summaries, edited comments.
15
+ * Approved reviews are opt-in minimize targets, not review work, and are never held.
16
+ * Held items leave every list — the report, the generated mutation IDs, and the seen-marker
17
+ * writes — so the next tick shows them. `rollup` is set only when something was held.
18
+ * Edited comments are held only alongside other held work: edits never block readiness, so a
19
+ * ready or terminal tick must not leave some of them unshown. `heldThreadReviewIds` names the
20
+ * parent reviews of held unresolved threads, which must not be minimized while those wait.
21
+ */
22
+ export function applyReviewOutputBudget(buckets, maxChars) {
23
+ const { changesRequested, threads, comments } = buckets;
24
+ const selection = selectReviewOutput([
25
+ changesRequested.visible.map(bodyItem),
26
+ [
27
+ ...threads.activeThreads.map(threadItem),
28
+ ...threads.resolutionOnlyThreads.map(threadItem),
29
+ ...comments.actionable.map(bodyItem),
30
+ ],
31
+ [...threads.firstLookThreads.map(threadItem), ...buckets.firstLookComments.map(bodyItem)],
32
+ [...buckets.firstLookSummaries.map(bodyItem), ...buckets.editedSummaries.map(bodyItem)],
33
+ buckets.editedComments.map(bodyItem),
34
+ ], maxChars);
35
+ if (selection.rollup === undefined)
36
+ return { buckets };
37
+ const nonEditedIds = [
38
+ ...changesRequested.visible,
39
+ ...threads.activeThreads,
40
+ ...threads.resolutionOnlyThreads,
41
+ ...comments.actionable,
42
+ ...threads.firstLookThreads,
43
+ ...buckets.firstLookComments,
44
+ ...buckets.firstLookSummaries,
45
+ ...buckets.editedSummaries,
46
+ ].map((item) => item.id);
47
+ if (nonEditedIds.every(selection.keep))
48
+ return { buckets };
49
+ const heldThreadReviewIds = [...threads.activeThreads, ...threads.resolutionOnlyThreads]
50
+ .filter((thread) => !selection.keep(thread.id))
51
+ .flatMap((thread) => (thread.reviewId !== undefined ? [thread.reviewId] : []));
52
+ const keep = (items) => items.filter((item) => selection.keep(item.id));
53
+ return {
54
+ buckets: {
55
+ changesRequested: {
56
+ visible: keep(changesRequested.visible),
57
+ toMarkSeen: keep(changesRequested.toMarkSeen),
58
+ },
59
+ threads: {
60
+ activeThreads: keep(threads.activeThreads),
61
+ resolutionOnlyThreads: keep(threads.resolutionOnlyThreads),
62
+ firstLookThreads: keep(threads.firstLookThreads),
63
+ toMarkSeen: keep(threads.toMarkSeen),
64
+ },
65
+ comments: {
66
+ actionable: keep(comments.actionable),
67
+ minimizeIds: comments.minimizeIds.filter(selection.keep),
68
+ toMarkSeen: keep(comments.toMarkSeen),
69
+ },
70
+ firstLookComments: keep(buckets.firstLookComments),
71
+ firstLookSummaries: keep(buckets.firstLookSummaries),
72
+ editedSummaries: keep(buckets.editedSummaries),
73
+ editedComments: keep(buckets.editedComments),
74
+ },
75
+ rollup: selection.rollup,
76
+ ...(heldThreadReviewIds.length > 0 && { heldThreadReviewIds }),
77
+ };
78
+ }
@@ -10,7 +10,11 @@ type StateKey = {
10
10
  /**
11
11
  * Outcome of the GraphQL transport's REST change detectors. `reused` replays the stored report
12
12
  * without any GraphQL request. Otherwise `digest` is what the detectors read before this tick's
13
- * GraphQL snapshot, recorded beside the new report by `recordWaitDetectors`.
13
+ * GraphQL snapshot, recorded beside the new report by `recordWaitDetectors`. `changed` is set
14
+ * when this read differs from the digest stored beside the report, or no digest is stored yet
15
+ * (the seeding tick): the stored report may predate what the detectors saw, so the tick must
16
+ * not reuse it through the GraphQL fingerprint either. A same-digest read past
17
+ * `poll.reconcileSeconds` leaves `changed` unset.
14
18
  */
15
19
  export type WaitDetectorResult = {
16
20
  reused: ShepherdReport;
@@ -18,6 +22,7 @@ export type WaitDetectorResult = {
18
22
  } | {
19
23
  digest: string;
20
24
  readAt: number;
25
+ changed?: true;
21
26
  };
22
27
  /**
23
28
  * On the GraphQL transport, run the REST change detectors for an idle stored report. Returns
@@ -83,7 +83,8 @@ export async function readWaitDetectors(prNumber, repo, stateKey, config, now =
83
83
  baseTipOid: observed.baseTipOid,
84
84
  };
85
85
  }
86
- return { digest: state.digest, readAt: now };
86
+ const changed = previous?.digest !== state.digest;
87
+ return { digest: state.digest, readAt: now, ...(changed && { changed: true }) };
87
88
  }
88
89
  /**
89
90
  * After a GraphQL tick, store the digest its detectors read beside the report that tick built.
@@ -7,6 +7,8 @@ type CheckOptions = GlobalOptions & {
7
7
  persistSeen?: boolean;
8
8
  fingerprintCache?: boolean;
9
9
  merge?: boolean;
10
+ /** Per-tick review-item body budget (`iterate.reviewOutputMaxChars`). Omitted or 0: no cap. */
11
+ reviewOutputMaxChars?: number;
10
12
  };
11
13
  export declare function runCheck(opts: CheckOptions, context?: CheckExecutionContext): Promise<ShepherdReport>;
12
14
  export {};
@@ -28,6 +28,7 @@ import { threadTranscriptBody } from "../threads/transcript.mjs";
28
28
  import { classifyThreadVisibility } from "../comments/thread-visibility.mjs";
29
29
  import { classifyReviewsForDisplay, classifyChangesRequestedReviewsForDisplay, } from "../comments/review-visibility.mjs";
30
30
  import { applySuppressedRuleAutoResolve } from "./rule-auto-resolve.mjs";
31
+ import { applyReviewOutputBudget } from "./check-review-budget.mjs";
31
32
  import { markReviewInlineThreadMarkers } from "../comments/review-thread-markers.mjs";
32
33
  import { isConfiguredBotAuthor, isHumanAuthor, normalizeBotUsernames, } from "../comments/authors.mjs";
33
34
  import { buildThreadMutationRouting, threadHasAuthorizedMutation, } from "./iterate/thread-mutation-routing.mjs";
@@ -70,7 +71,11 @@ async function runScopedCheck(opts, context, repo, prNumber, stateKey) {
70
71
  : undefined;
71
72
  if (detectors && "reused" in detectors)
72
73
  return refreshReused(detectors.reused, detectors.baseTipOid);
73
- const reuse = reuseFingerprint
74
+ // A detector change, or a first seeding read, means the stored report may predate what the
75
+ // detectors saw; an unchanged first-page fingerprint cannot vouch for it (it omits
76
+ // per-check-run fields), so take a full snapshot instead.
77
+ const detectorsChanged = detectors !== undefined && "changed" in detectors;
78
+ const reuse = reuseFingerprint && !detectorsChanged
74
79
  ? await fingerprintReuser(prNumber, repo, stateKey, config)
75
80
  : undefined;
76
81
  const batchOptions = {
@@ -215,13 +220,17 @@ async function runScopedCheck(opts, context, repo, prNumber, stateKey) {
215
220
  threadHasAuthorizedMutation(thread, replyThreadIds, resolveThreadIds))
216
221
  .map((thread) => thread.id));
217
222
  const threadVisibility = classifyThreadVisibility(visibleThreadCandidates, seenMap, botUsernames, repeatableThreadIds, resolveOtherHumanThreads);
218
- const firstLookComments = minimizedCommentCandidates.flatMap((c) => {
219
- const cls = classifyItem(c.id, c.body, seenMap);
220
- if (cls === "unchanged")
221
- return [];
222
- const base = { ...c, firstLookStatus: "minimized" };
223
- return cls === "edited" ? [{ ...base, edited: true }] : [base];
224
- });
223
+ const firstLookComments = minimizedCommentCandidates
224
+ .filter((c) => classifyItem(c.id, c.body, seenMap) === "new")
225
+ .map((c) => ({ ...c, firstLookStatus: "minimized" }));
226
+ // Edited PR comments never block readiness. Iterate shows them on the next action that is not
227
+ // wait/ready and writes their markers then, so an edit is never marked seen while hidden.
228
+ const editedComments = [
229
+ ...minimizedCommentCandidates
230
+ .filter((c) => classifyItem(c.id, c.body, seenMap) === "edited")
231
+ .map((c) => ({ ...c, edited: true })),
232
+ ...visibleCommentClassification.edited,
233
+ ];
225
234
  const firstLookSummaries = [];
226
235
  const editedSummaries = [];
227
236
  const seenSummaries = [];
@@ -251,7 +260,6 @@ async function runScopedCheck(opts, context, repo, prNumber, stateKey) {
251
260
  canGenerateItemMutation(review.id, review.body, batchData.viewerAuthorization?.viewerCanAdminister, "dismiss") ||
252
261
  visibleChangesRequestedIds.has(review.id));
253
262
  }).length;
254
- const approvedReviews = approvedReviewVisibility.visible;
255
263
  let status = computeStatus(verdict, threadVisibility.activeThreads.length + threadVisibility.resolutionOnlyThreads.length, visibleCommentClassification.actionable.length, mergeStatus, changesRequestedReviewCount, unreported.hasUnreportedRequired);
256
264
  status = enforceTransportReadiness(status, batchData);
257
265
  // Resolve any pending mergeability refresh (and the resulting MERGED/CLOSED short-circuit)
@@ -292,6 +300,16 @@ async function runScopedCheck(opts, context, repo, prNumber, stateKey) {
292
300
  filtered: merged.filtered,
293
301
  ignored: merged.ignored,
294
302
  }, mergeStatus.status);
303
+ // A deferred tick shows no review items, so it is never capped; its seen markers stay unwritten.
304
+ const { buckets: budgeted, rollup: reviewItems, heldThreadReviewIds, } = applyReviewOutputBudget({
305
+ changesRequested: changesRequestedReviewVisibility,
306
+ threads: threadVisibility,
307
+ comments: visibleCommentClassification,
308
+ firstLookComments,
309
+ firstLookSummaries,
310
+ editedSummaries,
311
+ editedComments,
312
+ }, deferWhileQueued ? 0 : (opts.reviewOutputMaxChars ?? 0));
295
313
  if (opts.persistSeen !== false) {
296
314
  const successfulAnnotations = [
297
315
  ...merged.passing,
@@ -304,11 +322,11 @@ async function runScopedCheck(opts, context, repo, prNumber, stateKey) {
304
322
  const deferredMarkSeen = deferWhileQueued
305
323
  ? []
306
324
  : [
307
- ...firstLookComments.map((c) => markSeen(stateKey, c.id, c.body)),
308
- ...threadVisibility.toMarkSeen.map((t) => markSeen(stateKey, t.id, threadTranscriptBody(t))),
309
- ...visibleCommentClassification.toMarkSeen.map((c) => markSeen(stateKey, c.id, c.body)),
310
- ...[...firstLookSummaries, ...editedSummaries].map((r) => markSeen(stateKey, r.id, r.body)),
311
- ...changesRequestedReviewVisibility.toMarkSeen.map((r) => markSeen(stateKey, r.id, r.body)),
325
+ ...budgeted.firstLookComments.map((c) => markSeen(stateKey, c.id, c.body)),
326
+ ...budgeted.threads.toMarkSeen.map((t) => markSeen(stateKey, t.id, threadTranscriptBody(t))),
327
+ ...budgeted.comments.toMarkSeen.map((c) => markSeen(stateKey, c.id, c.body)),
328
+ ...[...budgeted.firstLookSummaries, ...budgeted.editedSummaries].map((r) => markSeen(stateKey, r.id, r.body)),
329
+ ...budgeted.changesRequested.toMarkSeen.map((r) => markSeen(stateKey, r.id, r.body)),
312
330
  ...approvedReviewVisibility.toMarkSeen.map((r) => markSeen(stateKey, r.id, r.body)),
313
331
  ];
314
332
  await Promise.allSettled([
@@ -351,7 +369,7 @@ async function runScopedCheck(opts, context, repo, prNumber, stateKey) {
351
369
  partition: authorizedPartition,
352
370
  batch: batchData,
353
371
  });
354
- const visibleMutationThreadIds = new Set([...threadVisibility.activeThreads, ...threadVisibility.resolutionOnlyThreads].map((thread) => thread.id));
372
+ const visibleMutationThreadIds = new Set([...budgeted.threads.activeThreads, ...budgeted.threads.resolutionOnlyThreads].map((thread) => thread.id));
355
373
  const ruleAutoResolveThreadIds = [
356
374
  ...authorizedRuleAutoResolveThreadIds,
357
375
  ...[...deniedRuleAutoResolveThreadIds].filter((id) => visibleMutationThreadIds.has(id)),
@@ -400,27 +418,30 @@ async function runScopedCheck(opts, context, repo, prNumber, stateKey) {
400
418
  }),
401
419
  },
402
420
  threads: {
403
- actionable: threadVisibility.activeThreads,
404
- resolutionOnly: threadVisibility.resolutionOnlyThreads,
421
+ actionable: budgeted.threads.activeThreads,
422
+ resolutionOnly: budgeted.threads.resolutionOnlyThreads,
405
423
  autoResolved,
406
424
  autoResolveErrors,
407
425
  ...(errorReasons.length > 0 && { autoResolveErrorReasons: errorReasons }),
408
- firstLook: threadVisibility.firstLookThreads,
426
+ firstLook: budgeted.threads.firstLookThreads,
409
427
  ...(ruleAutoResolveThreadIds.length > 0
410
428
  ? { ruleAutoResolveIds: ruleAutoResolveThreadIds }
411
429
  : undefined),
412
430
  },
413
431
  comments: {
414
- actionable: visibleCommentClassification.actionable,
415
- minimizeIds: [...visibleCommentClassification.minimizeIds, ...ruleAutoResolveCommentIds],
416
- firstLook: firstLookComments,
432
+ actionable: budgeted.comments.actionable,
433
+ minimizeIds: [...budgeted.comments.minimizeIds, ...ruleAutoResolveCommentIds],
434
+ firstLook: budgeted.firstLookComments,
435
+ ...(budgeted.editedComments.length > 0 && { edited: budgeted.editedComments }),
417
436
  ...(autoMinimized.length > 0 && { autoMinimized }),
418
437
  },
419
- changesRequestedReviews,
438
+ changesRequestedReviews: budgeted.changesRequested.visible,
420
439
  reviewSummaries: seenSummaries,
421
- firstLookSummaries,
422
- editedSummaries,
423
- approvedReviews,
440
+ firstLookSummaries: budgeted.firstLookSummaries,
441
+ editedSummaries: budgeted.editedSummaries,
442
+ approvedReviews: approvedReviewVisibility.visible,
443
+ ...(reviewItems && { reviewItems }),
444
+ ...(heldThreadReviewIds && { heldThreadReviewIds }),
424
445
  ...(ruleAutoResolveReviewSummaryIds.length > 0
425
446
  ? { ruleAutoResolveReviewSummaryIds }
426
447
  : undefined),
@@ -37,5 +37,10 @@ export declare function buildResolveCommandInstruction(resolveCommand: ResolveCo
37
37
  export declare function buildFailingCheckInstructions(checks: AgentCheck[]): string[];
38
38
  /** Update the PR branch after an external blocker merges or closes. Never rerun that job. */
39
39
  export declare function buildReleasedBlockerInstruction(prNumber: number): string;
40
+ /** `iterate.reviewOutputMaxChars` held review items back; the rerun step that follows shows them. */
41
+ export declare function buildReviewSubsetInstruction(reviewItems: {
42
+ shown: number;
43
+ total: number;
44
+ }): string;
40
45
  /** Recurrence only. Commit, push, rerun, and SHA steps are earlier instructions. */
41
46
  export declare function buildFixCompletionInstruction(): string;
@@ -74,6 +74,10 @@ export function buildReleasedBlockerInstruction(prNumber) {
74
74
  ? "Update this PR branch from its base using the repository's branch-update procedure. Do not rerun the job; a rerun retests the old merge ref."
75
75
  : `Update this PR branch from its base with \`gh pr update-branch ${prNumber} --rebase\`. Do not rerun the job; a rerun retests the old merge ref.`;
76
76
  }
77
+ /** `iterate.reviewOutputMaxChars` held review items back; the rerun step that follows shows them. */
78
+ export function buildReviewSubsetInstruction(reviewItems) {
79
+ return `This is a subset: ${reviewItems.shown} of ${reviewItems.total} review items are shown, and only their IDs are in the commands above. After handling these items, rerun Shepherd for the next batch; do not fetch the held items yourself.`;
80
+ }
77
81
  /** Recurrence only. Commit, push, rerun, and SHA steps are earlier instructions. */
78
82
  export function buildFixCompletionInstruction() {
79
83
  return FIX_CODE_CONTINUATION;
@@ -5,7 +5,7 @@ export declare function classifyReviewSummaries(summaries: {
5
5
  firstLook: Review[];
6
6
  seen: Review[];
7
7
  edited: Review[];
8
- }, approvals: Review[], minimizeApprovals: boolean, minimizeComments?: MinimizeCommentsPolicy | undefined, botUsernames?: NormalizedBotUsernames, unresolvedThreads?: ReviewThread[], ruleAutoResolveIds?: string[]): {
8
+ }, approvals: Review[], minimizeApprovals: boolean, minimizeComments?: MinimizeCommentsPolicy | undefined, botUsernames?: NormalizedBotUsernames, unresolvedThreads?: ReadonlyArray<Pick<ReviewThread, "reviewId">>, ruleAutoResolveIds?: string[]): {
9
9
  minimizeIds: string[];
10
10
  selfMinimizeIds: string[];
11
11
  firstLookSummaries: Review[];
@@ -0,0 +1,3 @@
1
+ import type { IterateResult } from "../../types.mts";
2
+ /** Records the edited comments a result shows, so each edit is shown once. Returns the result. */
3
+ export declare function markEditedCommentsSeen(result: IterateResult): Promise<IterateResult>;
@@ -0,0 +1,10 @@
1
+ import { markSeen } from "../../state/seen-comments.mjs";
2
+ /** Records the edited comments a result shows, so each edit is shown once. Returns the result. */
3
+ export async function markEditedCommentsSeen(result) {
4
+ const [owner, repo] = result.repo.split("/");
5
+ if (!result.editedComments?.length || !owner || !repo)
6
+ return result;
7
+ const key = { owner, repo, pr: result.pr };
8
+ await Promise.allSettled(result.editedComments.map((c) => markSeen(key, c.id, c.body)));
9
+ return result;
10
+ }
@@ -9,7 +9,7 @@ import { buildFixInstructions } from "./render.mjs";
9
9
  import { buildRemovedQueueRecovery, buildStackQueueRemovalAcknowledgment } from "./merge.mjs";
10
10
  import { hasLogEvidence } from "./check-evidence.mjs";
11
11
  import { currentEjectionCommit, } from "./queue-recovery-instructions.mjs";
12
- import { buildReleasedBlockerInstruction } from "./check-instructions.mjs";
12
+ import { buildReleasedBlockerInstruction, buildReviewSubsetInstruction, } from "./check-instructions.mjs";
13
13
  import { buildNativeStackLayerRebase } from "./native-stack-rebase.mjs";
14
14
  import { lookupUpperLayerTrunkConflict } from "./stack-trunk-conflict.mjs";
15
15
  import { lookupMergedBasePullRequests } from "./merged-base-pull-requests.mjs";
@@ -78,6 +78,14 @@ function pendingReviewCommands(resolveCommand, resolveOnlyCommand) {
78
78
  return Object.keys(pending).length > 0 ? pending : undefined;
79
79
  }
80
80
  export async function handleFixCode(ctx) {
81
+ const result = await handleFixCodeTick(ctx);
82
+ // Every escalation from a capped tick carries the rollup: the reader sees a subset.
83
+ const { reviewItems } = ctx.report;
84
+ if (result.action !== "escalate" || reviewItems === undefined)
85
+ return result;
86
+ return { ...result, escalate: { ...result.escalate, reviewItems } };
87
+ }
88
+ async function handleFixCodeTick(ctx) {
81
89
  const { base, report, opts, headSha, stallKey, prNumber, stallTimeoutSeconds, repoOwner, repoName, reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, botUsernames, releasedCheckNames = EMPTY_RELEASED, ruleAutoResolveThreadIds, repairInstructions, } = ctx;
82
90
  const prReference = formatPrUrl(report.repo, prNumber);
83
91
  const failingChecks = report.checks.failing;
@@ -221,6 +229,7 @@ export async function handleFixCode(ctx) {
221
229
  editedSummaries.length > 0 ||
222
230
  report.threads.firstLook.length > 0 ||
223
231
  report.comments.firstLook.length > 0 ||
232
+ report.reviewItems !== undefined ||
224
233
  checks.some((check) => (check.annotations?.length ?? 0) > 0) ||
225
234
  failingAgentChecks.some((check) => releasedCheckNames.has(check.name) ||
226
235
  belongsToActiveWorkflowRun(check) ||
@@ -348,6 +357,12 @@ export async function handleFixCode(ctx) {
348
357
  if (completion !== undefined)
349
358
  instructions.push(completion);
350
359
  }
360
+ if (report.reviewItems) {
361
+ const completion = instructions.pop();
362
+ instructions.push(buildReviewSubsetInstruction(report.reviewItems));
363
+ if (completion !== undefined)
364
+ instructions.push(completion);
365
+ }
351
366
  if (repairInstructions && repairInstructions.length > 0) {
352
367
  instructions.unshift(...repairInstructions);
353
368
  }
@@ -396,6 +411,7 @@ export async function handleFixCode(ctx) {
396
411
  firstLookComments,
397
412
  inProgressRunIds,
398
413
  protectedRuns,
414
+ ...(report.reviewItems && { reviewItems: report.reviewItems }),
399
415
  },
400
416
  cancelled,
401
417
  };
@@ -1,10 +1,15 @@
1
1
  import { blockedReasonFromRequirements } from "../../merge-status/requirements-format.mjs";
2
+ import { capList, moreSuffix } from "../../util/cap-list.mjs";
3
+ /** GitHub statuses for a check that has not started running yet. */
4
+ const QUEUED_STATUSES = new Set(["QUEUED", "WAITING", "PENDING", "REQUESTED"]);
2
5
  export function buildSummary(report) {
6
+ const queued = report.checks.inProgress.filter((c) => QUEUED_STATUSES.has(c.status)).length;
3
7
  return {
4
8
  passing: report.checks.passing.length,
5
9
  skipped: report.checks.skipped.length,
6
10
  filtered: report.checks.filtered.length,
7
- inProgress: report.checks.inProgress.length,
11
+ inProgress: report.checks.inProgress.length - queued,
12
+ queued,
8
13
  superseded: report.checks.supersededNames?.length ?? 0,
9
14
  };
10
15
  }
@@ -111,13 +116,12 @@ export function buildWaitLog(base) {
111
116
  const counts = [
112
117
  ...(summary.passing > 0 ? [`${summary.passing} passing`] : []),
113
118
  ...(summary.inProgress > 0 ? [`${summary.inProgress} in-progress`] : []),
119
+ ...(summary.queued > 0 ? [`${summary.queued} queued`] : []),
114
120
  ];
115
121
  const parts = counts.length > 0 ? [counts.join(", ")] : [];
116
122
  if ((base.inProgressChecks?.length ?? 0) > 0) {
117
- parts.push(`active checks: ${base
118
- .inProgressChecks.slice(0, 5)
119
- .map((c) => c.name)
120
- .join(", ")}`);
123
+ const { shown, hidden } = capList(base.inProgressChecks);
124
+ parts.push(`active checks: ${shown.map((c) => c.name).join(", ")}${moreSuffix(hidden)}`);
121
125
  }
122
126
  switch (base.mergeStatus) {
123
127
  case "BLOCKED": {