pr-shepherd 0.59.3 → 0.60.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 (54) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/bin/api.d.mts +1 -1
  3. package/bin/cli/edited-comments-format.d.mts +14 -0
  4. package/bin/cli/edited-comments-format.mjs +39 -0
  5. package/bin/cli/fix-formatter.mjs +5 -2
  6. package/bin/cli/iterate-activity-formatter.d.mts +1 -1
  7. package/bin/cli/iterate-activity-formatter.mjs +4 -5
  8. package/bin/cli/iterate-check-names.d.mts +5 -0
  9. package/bin/cli/iterate-check-names.mjs +17 -0
  10. package/bin/cli/iterate-formatter.mjs +15 -19
  11. package/bin/cli/iterate-lean.mjs +19 -11
  12. package/bin/cli/iterate-verbose.mjs +7 -2
  13. package/bin/cli/list-formatters.d.mts +0 -3
  14. package/bin/cli/list-formatters.mjs +1 -5
  15. package/bin/commands/check-review-budget.d.mts +39 -0
  16. package/bin/commands/check-review-budget.mjs +78 -0
  17. package/bin/commands/check-wait-detectors.d.mts +6 -1
  18. package/bin/commands/check-wait-detectors.mjs +2 -1
  19. package/bin/commands/check.d.mts +2 -0
  20. package/bin/commands/check.mjs +46 -25
  21. package/bin/commands/iterate/check-instructions.d.mts +5 -0
  22. package/bin/commands/iterate/check-instructions.mjs +4 -0
  23. package/bin/commands/iterate/classify.d.mts +1 -1
  24. package/bin/commands/iterate/edited-comment-markers.d.mts +3 -0
  25. package/bin/commands/iterate/edited-comment-markers.mjs +10 -0
  26. package/bin/commands/iterate/fix-code.mjs +17 -1
  27. package/bin/commands/iterate/helpers.mjs +9 -5
  28. package/bin/commands/iterate/index.mjs +44 -17
  29. package/bin/commands/iterate/render.mjs +1 -4
  30. package/bin/commands/poll-rate-limit-cancel.mjs +1 -1
  31. package/bin/commands/poll-seen-options.d.mts +8 -0
  32. package/bin/commands/poll-seen-options.mjs +7 -0
  33. package/bin/commands/poll.mjs +7 -5
  34. package/bin/comments/review-output-budget.d.mts +32 -0
  35. package/bin/comments/review-output-budget.mjs +55 -0
  36. package/bin/comments/visible-comments.d.mts +3 -1
  37. package/bin/comments/visible-comments.mjs +10 -5
  38. package/bin/config/load.d.mts +6 -0
  39. package/bin/config/load.mjs +7 -0
  40. package/bin/config.json +2 -1
  41. package/bin/github/poll-summary-fingerprint.mjs +5 -1
  42. package/bin/github/poll-summary-review.mjs +2 -1
  43. package/bin/reporters/agent.d.mts +1 -3
  44. package/bin/reporters/agent.mjs +0 -1
  45. package/bin/types/escalate.d.mts +9 -0
  46. package/bin/types/iterate.d.mts +19 -1
  47. package/bin/types/report.d.mts +23 -4
  48. package/bin/util/cap-list.d.mts +7 -0
  49. package/bin/util/cap-list.mjs +13 -0
  50. package/package.json +2 -2
  51. package/plugins/pr-shepherd/.claude-plugin/plugin.json +1 -1
  52. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  53. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  54. package/plugins/pr-shepherd/.mcp.json +1 -1
@@ -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": {
@@ -9,12 +9,13 @@ 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 { isConfiguredBotAuthor, normalizeBotUsernames } from "../../comments/authors.mjs";
12
+ import { normalizeBotUsernames } from "../../comments/authors.mjs";
13
13
  import { autoMinimizeComments } from "../../comments/resolve.mjs";
14
14
  import { hasCheckDrivenActionableWork } from "../check-annotations.mjs";
15
15
  import { buildReadyMergeOutcome, handleActiveMergeState } from "./merge-state.mjs";
16
16
  import { buildIterateBase } from "./base.mjs";
17
17
  import { markReadyIfAuthorized } from "./mark-ready.mjs";
18
+ import { classifyItem, loadSeenMap } from "../../state/seen-comments.mjs";
18
19
  import { withIterateApiUsage } from "./run.mjs";
19
20
  import { fetchRawSummaryPr } from "../../github/poll-summary.mjs";
20
21
  import { summarizePollSummaryPr } from "../../github/poll-summary-projector.mjs";
@@ -29,10 +30,35 @@ import { buildUnreportedFixResult, planUnreportedRequired } from "./unreported-r
29
30
  import { createCheckExecutionContext } from "../check-execution-context.mjs";
30
31
  import { transportReadinessGaps } from "../../github/transport-evidence.mjs";
31
32
  import { buildEscalateHumanMessage } from "./escalate.mjs";
33
+ import { markEditedCommentsSeen } from "./edited-comment-markers.mjs";
32
34
  export function runIterate(opts) {
33
- return withIterateApiUsage(opts, () => runIterateCore(opts));
35
+ return withIterateApiUsage(opts, async () => {
36
+ const tick = {};
37
+ return attachEditedComments(await runIterateCore(opts, tick), tick, opts);
38
+ });
39
+ }
40
+ // Edited PR comments are not readiness work. Hold them while the PR waits or counts down, and show
41
+ // each once on any other action (including MERGE after the ready delay), writing its marker then.
42
+ // The poll loop defers the write to the result it returns, since it can discard or refresh a tick.
43
+ // A replayed cached report can carry edits already shown, so re-check the current markers.
44
+ async function attachEditedComments(result, tick, opts) {
45
+ const candidates = tick.report?.comments.edited ?? [];
46
+ if (candidates.length === 0 || result.action === "wait" || result.action === "ready")
47
+ return result;
48
+ if (result.action === "cancel" && (result.reason === "merged" || result.reason === "closed"))
49
+ return result;
50
+ const [owner, repo] = result.repo.split("/");
51
+ if (!owner || !repo)
52
+ return result;
53
+ const key = { owner, repo, pr: result.pr };
54
+ const seen = await loadSeenMap(key);
55
+ const edited = candidates.filter((c) => classifyItem(c.id, c.body, seen) === "edited");
56
+ if (edited.length === 0)
57
+ return result;
58
+ const withEdits = { ...result, editedComments: edited };
59
+ return opts.deferEditedCommentMarkers ? withEdits : markEditedCommentsSeen(withEdits);
34
60
  }
35
- async function runIterateCore(opts) {
61
+ async function runIterateCore(opts, tick) {
36
62
  const config = loadConfig();
37
63
  const botUsernames = normalizeBotUsernames(config.botUsernames);
38
64
  const readyDelaySeconds = opts.readyDelaySeconds ?? config.watch.readyDelayMinutes * 60;
@@ -51,7 +77,9 @@ async function runIterateCore(opts) {
51
77
  // compatibility key in the config loader.
52
78
  autoResolve: true,
53
79
  autoMinimizeSuppressed: config.actions.autoMinimizeSuppressed,
80
+ reviewOutputMaxChars: config.iterate.reviewOutputMaxChars,
54
81
  }, checkContext);
82
+ tick.report = report;
55
83
  const [repoOwner, repoName] = report.repo.split("/");
56
84
  if (!repoOwner || !repoName) {
57
85
  throw new ShepherdError(`Unexpected repo format: "${report.repo}" (expected "owner/name")`, EXIT.DATAERR);
@@ -79,7 +107,11 @@ async function runIterateCore(opts) {
79
107
  firstLook: report.firstLookSummaries,
80
108
  seen: report.reviewSummaries,
81
109
  edited: report.editedSummaries,
82
- }, report.approvedReviews, config.iterate.minimizeApprovals, config.iterate.minimizeComments, botUsernames, [...report.threads.actionable, ...report.threads.resolutionOnly], report.ruleAutoResolveReviewSummaryIds);
110
+ }, report.approvedReviews, config.iterate.minimizeApprovals, config.iterate.minimizeComments, botUsernames, [
111
+ ...report.threads.actionable,
112
+ ...report.threads.resolutionOnly,
113
+ ...(report.heldThreadReviewIds ?? []).map((reviewId) => ({ reviewId })),
114
+ ], report.ruleAutoResolveReviewSummaryIds);
83
115
  // Minimize already-seen review summaries in-process so they never become agent-facing work.
84
116
  // GitHub can still return a null/error/rate-limit result per ID without
85
117
  // throwing (autoMinimizeComments reports this via `errors`, not a rejection);
@@ -94,20 +126,16 @@ async function runIterateCore(opts) {
94
126
  if (unminimized.length > 0)
95
127
  reviewSummaryIds = [...reviewSummaryIds, ...unminimized];
96
128
  }
97
- // A first look at an already-minimized comment is only an acknowledgment.
98
- // A non-bot edit that no longer matches a noise rule may carry new feedback;
99
- // it must restart the ready delay and void an existing READY receipt. Bots
100
- // regenerate minimized summaries on their own, so their edits surface for
101
- // acknowledgment without restarting the countdown.
102
- const editedMinimizedFeedback = report.comments.firstLook.some((comment) => comment.isMinimized === true &&
103
- comment.edited === true &&
104
- !isConfiguredBotAuthor(comment, botUsernames));
105
- const hasReadinessWork = report.threads.actionable.length > 0 ||
129
+ // A first look at an already-minimized comment is only an acknowledgment. Edited PR comments
130
+ // (report.comments.edited) are neither: they never block readiness or restart the ready delay.
131
+ // Review items held back by iterate.reviewOutputMaxChars keep the PR out of ready/merge: a
132
+ // capped tick that shows only first-look comments still counts, since held work follows.
133
+ const hasReadinessWork = (report.reviewItems !== undefined && report.comments.firstLook.length > 0) ||
134
+ report.threads.actionable.length > 0 ||
106
135
  report.threads.resolutionOnly.length > 0 ||
107
136
  report.threads.firstLook.length > 0 ||
108
137
  (report.threads.ruleAutoResolveIds?.length ?? 0) > 0 ||
109
138
  report.comments.actionable.length > 0 ||
110
- editedMinimizedFeedback ||
111
139
  (report.comments.minimizeIds?.length ?? 0) > 0 ||
112
140
  report.changesRequestedReviews.length > 0 ||
113
141
  hasCheckDrivenActionableWork(reportForWork.checks, report.mergeStatus.status) ||
@@ -128,7 +156,7 @@ async function runIterateCore(opts) {
128
156
  !hasReadinessWork &&
129
157
  !activeMerge &&
130
158
  staleAncestry === null;
131
- const receiptCurrent = await revalidateReadyReceipt(receiptKey, report, hasReadinessWork, editedMinimizedFeedback, checkContext);
159
+ const receiptCurrent = await revalidateReadyReceipt(receiptKey, report, hasReadinessWork, checkContext);
132
160
  const headSha = report.headSha ?? "unknown";
133
161
  // The elapsed marker survives a hidden-comment acknowledgement tick and is
134
162
  // consumed only when this tick cancels or merges. A receipt that is still
@@ -400,7 +428,7 @@ async function recordReadyReceipt(key, report, context) {
400
428
  }
401
429
  }
402
430
  /** Clear a stale READY receipt; return whether a current receipt remains. */
403
- async function revalidateReadyReceipt(key, report, hasReadinessWork, editedMinimizedFeedback, context) {
431
+ async function revalidateReadyReceipt(key, report, hasReadinessWork, context) {
404
432
  const receipt = await readReadyReceipt(key);
405
433
  if (!receipt)
406
434
  return false;
@@ -413,7 +441,6 @@ async function revalidateReadyReceipt(key, report, hasReadinessWork, editedMinim
413
441
  !report.headSha ||
414
442
  !report.baseRefOid ||
415
443
  transportReadinessGaps(report, report.mergeStatus.mergeStateStatus).length > 0 ||
416
- editedMinimizedFeedback ||
417
444
  (!retainQueuedReceipt && (report.status !== "READY" || hasReadinessWork))) {
418
445
  await clearReadyReceipt(key);
419
446
  return false;
@@ -49,10 +49,7 @@ instructionStyle = "inline") {
49
49
  instructions.push("Review every item under `## First-look items` before acting.");
50
50
  if (firstLookSummaries.length > 0)
51
51
  instructions.push("Read each body under `## Review summaries (first look)` before acting.");
52
- const editedTotal = editedSummaries.length +
53
- actionableComments.filter((c) => c.edited).length +
54
- firstLookThreads.filter((t) => t.edited).length +
55
- firstLookComments.filter((c) => c.edited).length;
52
+ const editedTotal = editedSummaries.length + firstLookThreads.filter((t) => t.edited).length;
56
53
  if (editedTotal > 0)
57
54
  instructions.push("Read every item marked `[edited since first look]`, including edited summaries and edited first-look bullets, before deciding whether to resolve a matching thread.");
58
55
  if (unlocatedThreads.length > 0)
@@ -27,7 +27,7 @@ export function onePrCancelFromPulls(explicit, repoSlug, pulls) {
27
27
  isDraft: pull.draft,
28
28
  shouldCancel: true,
29
29
  remainingSeconds: 0,
30
- summary: { passing: 0, skipped: 0, filtered: 0, inProgress: 0, superseded: 0 },
30
+ summary: { passing: 0, skipped: 0, filtered: 0, inProgress: 0, queued: 0, superseded: 0 },
31
31
  baseBranch: pull.baseRefName,
32
32
  branchProtection: null,
33
33
  checks: [],
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Seen-state options for one poll tick. The loop can discard or refresh a tick's result, so
3
+ * edited-comment markers are written only for the result it returns.
4
+ */
5
+ export declare function pollTickSeenOptions(debounceSeconds: number, pastDebounce: boolean): {
6
+ persistSeen: boolean;
7
+ deferEditedCommentMarkers: boolean;
8
+ };
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Seen-state options for one poll tick. The loop can discard or refresh a tick's result, so
3
+ * edited-comment markers are written only for the result it returns.
4
+ */
5
+ export function pollTickSeenOptions(debounceSeconds, pastDebounce) {
6
+ return { persistSeen: debounceSeconds === 0 || pastDebounce, deferEditedCommentMarkers: true };
7
+ }
@@ -1,4 +1,6 @@
1
1
  import { runIterate } from "./iterate/index.mjs";
2
+ import { pollTickSeenOptions } from "./poll-seen-options.mjs";
3
+ import { markEditedCommentsSeen } from "./iterate/edited-comment-markers.mjs";
2
4
  import { sleep } from "../util/sleep.mjs";
3
5
  import { withPollApiUsage } from "./poll-run.mjs";
4
6
  import { loadConfig } from "../config/load.mjs";
@@ -26,6 +28,7 @@ async function runPollCore(opts) {
26
28
  const verbose = opts.verbose === true;
27
29
  const quietStatus = quietStatusOpt === true;
28
30
  const untilTerminal = untilTerminalOpt === true;
31
+ const swallowsMarkReady = untilTerminal || iterateOpts.merge === true;
29
32
  let lastWaitSignature = null;
30
33
  let pendingQuotaWarning;
31
34
  let prNumber = opts.prNumber;
@@ -34,6 +37,7 @@ async function runPollCore(opts) {
34
37
  while (true) {
35
38
  tick += 1;
36
39
  const pastDebounce = debounceUntil !== null && Date.now() >= debounceUntil;
40
+ const seenOptions = pollTickSeenOptions(debounceSeconds, pastDebounce);
37
41
  const remainingBefore = untilTerminal
38
42
  ? Number.POSITIVE_INFINITY
39
43
  : timeoutMs - (Date.now() - start);
@@ -41,7 +45,7 @@ async function runPollCore(opts) {
41
45
  const iterateTick = (fingerprintCache) => runIterate({
42
46
  ...iterateOpts,
43
47
  prNumber,
44
- persistSeen: debounceSeconds === 0 || pastDebounce,
48
+ ...seenOptions,
45
49
  fingerprintCache,
46
50
  deferQuotaWarning: !untilTerminal,
47
51
  quotaWarningMinimumPollIntervalMinutes: intervalSeconds / 60,
@@ -123,9 +127,7 @@ async function runPollCore(opts) {
123
127
  await sleep(sleepMs);
124
128
  continue;
125
129
  }
126
- if ((untilTerminal || iterateOpts.merge) &&
127
- lastResult.action === "mark_ready" &&
128
- !pastDebounce) {
130
+ if (swallowsMarkReady && lastResult.action === "mark_ready" && !pastDebounce) {
129
131
  debounceUntil = null;
130
132
  const elapsedMs = Date.now() - start;
131
133
  const sleepMs = quotaPollIntervalMs(quotaBands, lastResult.apiUsage, intervalMs, MAX_TIMER_MS, getGithubTransport());
@@ -153,5 +155,5 @@ async function runPollCore(opts) {
153
155
  await refreshIfReturning();
154
156
  break;
155
157
  }
156
- return lastResult;
158
+ return markEditedCommentsSeen(lastResult);
157
159
  }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Per-tick output budget for review items (see `iterate.reviewOutputMaxChars`).
3
+ *
4
+ * Fetching is complete; only the rendered volume is capped. When the bodies of every surfaced
5
+ * review item fit the budget, nothing changes. Otherwise items are taken in a fixed priority
6
+ * order — group by group, oldest first within a group — until the next item would exceed the
7
+ * budget (at least one item is always shown). Every other budgeted item is held: the caller
8
+ * omits it from the report, from generated mutation IDs, and from seen-marker writes, so a
9
+ * later tick shows it.
10
+ */
11
+ /** One review item's identity, age, and rendered-body cost in characters. */
12
+ export interface BudgetItem {
13
+ id: string;
14
+ createdAtUnix?: number;
15
+ cost: number;
16
+ }
17
+ /** Raw rollup emitted only when items were held: `shown` of `total` review items this tick. */
18
+ export interface ReviewItemRollup {
19
+ shown: number;
20
+ total: number;
21
+ }
22
+ export interface ReviewOutputSelection {
23
+ /** False only for a budgeted item held back this tick. Unbudgeted IDs are always kept. */
24
+ keep: (id: string) => boolean;
25
+ rollup?: ReviewItemRollup;
26
+ }
27
+ /**
28
+ * Select which review items to show. `groups` is in priority order; an ID that appears in more
29
+ * than one group (e.g. a thread that is both resolution-only and first-look) is counted and
30
+ * charged once, in its first group. `maxChars <= 0` disables the cap.
31
+ */
32
+ export declare function selectReviewOutput(groups: ReadonlyArray<ReadonlyArray<BudgetItem>>, maxChars: number): ReviewOutputSelection;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Per-tick output budget for review items (see `iterate.reviewOutputMaxChars`).
3
+ *
4
+ * Fetching is complete; only the rendered volume is capped. When the bodies of every surfaced
5
+ * review item fit the budget, nothing changes. Otherwise items are taken in a fixed priority
6
+ * order — group by group, oldest first within a group — until the next item would exceed the
7
+ * budget (at least one item is always shown). Every other budgeted item is held: the caller
8
+ * omits it from the report, from generated mutation IDs, and from seen-marker writes, so a
9
+ * later tick shows it.
10
+ */
11
+ const KEEP_ALL = { keep: () => true };
12
+ function oldestFirst(items) {
13
+ // Unknown ages sort last; Array.prototype.sort is stable, so fetch order breaks ties.
14
+ const age = (item) => item.createdAtUnix ?? Number.MAX_SAFE_INTEGER;
15
+ return [...items].sort((a, b) => age(a) - age(b));
16
+ }
17
+ /**
18
+ * Select which review items to show. `groups` is in priority order; an ID that appears in more
19
+ * than one group (e.g. a thread that is both resolution-only and first-look) is counted and
20
+ * charged once, in its first group. `maxChars <= 0` disables the cap.
21
+ */
22
+ export function selectReviewOutput(groups, maxChars) {
23
+ if (maxChars <= 0)
24
+ return KEEP_ALL;
25
+ const seen = new Set();
26
+ const ordered = [];
27
+ for (const group of groups) {
28
+ const unique = group.filter((item) => {
29
+ if (seen.has(item.id))
30
+ return false;
31
+ seen.add(item.id);
32
+ return true;
33
+ });
34
+ ordered.push(...oldestFirst(unique));
35
+ }
36
+ const totalCost = ordered.reduce((sum, item) => sum + item.cost, 0);
37
+ if (totalCost <= maxChars)
38
+ return KEEP_ALL;
39
+ const shown = new Set();
40
+ let used = 0;
41
+ for (const item of ordered) {
42
+ if (shown.size > 0 && used + item.cost > maxChars)
43
+ break;
44
+ shown.add(item.id);
45
+ used += item.cost;
46
+ }
47
+ const held = new Set(ordered.filter((item) => !shown.has(item.id)).map((item) => item.id));
48
+ // The at-least-one rule can show every item despite the budget: nothing is held, no rollup.
49
+ if (held.size === 0)
50
+ return KEEP_ALL;
51
+ return {
52
+ keep: (id) => !held.has(id),
53
+ rollup: { shown: shown.size, total: ordered.length },
54
+ };
55
+ }
@@ -1,11 +1,13 @@
1
1
  import { type SeenMarker } from "../state/seen-comments.mts";
2
2
  import type { MinimizeCommentsPolicy } from "../config/load.mts";
3
- import type { ActionableComment, PrComment } from "../types.mts";
3
+ import type { ActionableComment, EditedComment, PrComment } from "../types.mts";
4
4
  import type { NormalizedBotUsernames } from "./authors.mts";
5
5
  interface VisibleCommentClassification {
6
6
  actionable: ActionableComment[];
7
7
  minimizeIds: string[];
8
8
  toMarkSeen: ActionableComment[];
9
+ /** Seen comments whose body changed. Not readiness work; iterate surfaces and marks them. */
10
+ edited: EditedComment[];
9
11
  }
10
12
  export declare function classifyVisibleComments(comments: PrComment[], seenMap: Map<string, SeenMarker>, minimizeComments: MinimizeCommentsPolicy | undefined, botUsernames?: NormalizedBotUsernames, deniedMinimizeIds?: ReadonlySet<string>): VisibleCommentClassification;
11
13
  export {};
@@ -5,7 +5,14 @@ export function classifyVisibleComments(comments, seenMap, minimizeComments, bot
5
5
  const actionable = [];
6
6
  const minimizeIds = [];
7
7
  const toMarkSeen = [];
8
+ const edited = [];
8
9
  for (const c of comments.filter((comment) => !comment.isMinimized)) {
10
+ const cls = classifyItem(c.id, c.body, seenMap);
11
+ // An edit is never readiness work, even when the comment is eligible for minimization.
12
+ if (cls === "edited") {
13
+ edited.push({ ...c, edited: true });
14
+ continue;
15
+ }
9
16
  if (!deniedMinimizeIds.has(c.id) &&
10
17
  canGenerateGithubMutation(c.viewerCanMinimize, "minimize") &&
11
18
  shouldMinimizeAuthor(c.authorType, minimizeComments, c.author, botUsernames)) {
@@ -14,12 +21,10 @@ export function classifyVisibleComments(comments, seenMap, minimizeComments, bot
14
21
  toMarkSeen.push(c); // prevents re-surfacing as first-look after GitHub marks it minimized
15
22
  continue;
16
23
  }
17
- const cls = classifyItem(c.id, c.body, seenMap);
18
24
  if (cls === "unchanged")
19
25
  continue;
20
- const comment = cls === "edited" ? { ...c, edited: true } : c;
21
- actionable.push(comment);
22
- toMarkSeen.push(comment);
26
+ actionable.push(c);
27
+ toMarkSeen.push(c);
23
28
  }
24
- return { actionable, minimizeIds, toMarkSeen };
29
+ return { actionable, minimizeIds, toMarkSeen, edited };
25
30
  }