pr-shepherd 0.35.0 → 0.37.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 (91) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +32 -12
  3. package/bin/checks/conclusions.d.mts +6 -0
  4. package/bin/checks/conclusions.mjs +9 -0
  5. package/bin/checks/triage.mjs +11 -3
  6. package/bin/cli/args.mjs +1 -0
  7. package/bin/cli/default-poll.mjs +1 -0
  8. package/bin/cli/fix-formatter.mjs +4 -2
  9. package/bin/cli/help-command-pages.d.mts +1 -1
  10. package/bin/cli/help-iterate-poll-pages.d.mts +1 -1
  11. package/bin/cli/help-iterate-poll-pages.mjs +10 -5
  12. package/bin/cli/help-top-page.d.mts +1 -1
  13. package/bin/cli/help-top-page.mjs +2 -1
  14. package/bin/cli/help.d.mts +2 -2
  15. package/bin/cli/iterate-formatter.mjs +11 -2
  16. package/bin/cli/iterate-lean.mjs +1 -0
  17. package/bin/cli/poll-handler.mjs +7 -0
  18. package/bin/commands/check-annotations.d.mts +16 -3
  19. package/bin/commands/check-annotations.mjs +37 -1
  20. package/bin/commands/check.d.mts +1 -0
  21. package/bin/commands/check.mjs +35 -28
  22. package/bin/commands/commit-suggestion.mjs +11 -5
  23. package/bin/commands/iterate/fix-code.mjs +13 -6
  24. package/bin/commands/iterate/helpers.d.mts +1 -0
  25. package/bin/commands/iterate/helpers.mjs +15 -6
  26. package/bin/commands/iterate/index.mjs +5 -10
  27. package/bin/commands/iterate/render.mjs +9 -5
  28. package/bin/commands/iterate/stall.mjs +5 -0
  29. package/bin/commands/poll.d.mts +2 -0
  30. package/bin/commands/poll.mjs +51 -17
  31. package/bin/commands/ready-delay.mjs +3 -13
  32. package/bin/config/load.mjs +67 -25
  33. package/bin/github/batch-page-helpers.d.mts +45 -0
  34. package/bin/github/batch-page-helpers.mjs +63 -0
  35. package/bin/github/batch-page.d.mts +14 -0
  36. package/bin/github/batch-page.mjs +62 -0
  37. package/bin/github/batch-parse-suites.d.mts +4 -0
  38. package/bin/github/batch-parse-suites.mjs +25 -0
  39. package/bin/github/batch-parser-helpers.mjs +1 -0
  40. package/bin/github/batch-parsers-rules.d.mts +6 -0
  41. package/bin/github/batch-parsers-rules.mjs +122 -0
  42. package/bin/github/batch-parsers.mjs +6 -0
  43. package/bin/github/batch-raw-rules.d.mts +59 -0
  44. package/bin/github/batch-raw-rules.mjs +1 -0
  45. package/bin/github/batch-raw-types.d.mts +25 -11
  46. package/bin/github/batch.d.mts +2 -0
  47. package/bin/github/batch.mjs +14 -120
  48. package/bin/github/gql/batch-pr-page.gql +194 -0
  49. package/bin/github/gql/batch-pr.gql +79 -21
  50. package/bin/github/gql/commit-suggestion-thread.gql +40 -0
  51. package/bin/github/http.d.mts +2 -1
  52. package/bin/github/http.mjs +2 -1
  53. package/bin/github/pagination.d.mts +3 -2
  54. package/bin/github/pagination.mjs +3 -2
  55. package/bin/github/queries.d.mts +4 -0
  56. package/bin/github/queries.mjs +4 -0
  57. package/bin/github/rest-http.d.mts +6 -1
  58. package/bin/github/rest-http.mjs +25 -86
  59. package/bin/github/rest-text.d.mts +1 -0
  60. package/bin/github/rest-text.mjs +88 -0
  61. package/bin/github/suggestion-thread.d.mts +9 -0
  62. package/bin/github/suggestion-thread.mjs +45 -0
  63. package/bin/github/thread-comments.mjs +12 -8
  64. package/bin/merge-status/derive.mjs +2 -0
  65. package/bin/merge-status/requirements-format.d.mts +3 -0
  66. package/bin/merge-status/requirements-format.mjs +88 -0
  67. package/bin/merge-status/requirements.d.mts +2 -0
  68. package/bin/merge-status/requirements.mjs +51 -0
  69. package/bin/reporters/agent.mjs +0 -3
  70. package/bin/state/base.d.mts +9 -0
  71. package/bin/state/base.mjs +23 -0
  72. package/bin/state/bot-cr-seen.mjs +3 -13
  73. package/bin/state/fix-attempts.mjs +3 -13
  74. package/bin/state/iterate-stall.mjs +3 -13
  75. package/bin/state/seen-comments.mjs +6 -13
  76. package/bin/types/check-classification.d.mts +2 -2
  77. package/bin/types/github.d.mts +6 -7
  78. package/bin/types/iterate.d.mts +6 -7
  79. package/bin/types/merge-requirements.d.mts +82 -0
  80. package/bin/types/merge-requirements.mjs +2 -0
  81. package/bin/types/report.d.mts +5 -5
  82. package/bin/types.d.mts +1 -0
  83. package/bin/types.mjs +1 -0
  84. package/bin/util/path-segment.d.mts +2 -0
  85. package/bin/util/path-segment.mjs +2 -0
  86. package/bin/util/pool.d.mts +2 -0
  87. package/bin/util/pool.mjs +18 -0
  88. package/package.json +1 -1
  89. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  90. package/plugins/pr-shepherd/.codex.mcp.json +1 -1
  91. package/plugins/pr-shepherd/.mcp.json +1 -1
@@ -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 { attachUnseenCheckAnnotations } from "./check-annotations.mjs";
10
+ import { attachAndMergeCheckAnnotations } 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";
@@ -38,7 +38,9 @@ export async function runCheck(opts) {
38
38
  if (mergeStatus.state === "MERGED" || mergeStatus.state === "CLOSED") {
39
39
  return buildTerminalReport(prNumber, repo, batchData, mergeStatus, mergeStatus.state);
40
40
  }
41
- const startupFailureChecks = await fetchStartupFailureChecks(repo, batchData.headRefOid, prNumber);
41
+ const startupFailureChecks = result.checkSuitesComplete
42
+ ? []
43
+ : await fetchStartupFailureChecks(repo, batchData.headRefOid, prNumber);
42
44
  const allChecks = mergeStartupFailureChecks(batchData.checks, startupFailureChecks);
43
45
  const classifiedChecks = classifyChecks(allChecks);
44
46
  const verdict = getCiVerdict(classifiedChecks);
@@ -47,6 +49,7 @@ export async function runCheck(opts) {
47
49
  const inProgress = classifiedChecks.filter((c) => c.category === "in_progress");
48
50
  const skipped = classifiedChecks.filter((c) => c.category === "skipped");
49
51
  const filtered = classifiedChecks.filter((c) => c.category === "filtered");
52
+ const ignored = classifiedChecks.filter((c) => c.category === "ignored");
50
53
  const triagedBase = failing.length > 0 && !opts.skipTriage ? await triageFailingChecks(failing, repo) : failing;
51
54
  const stateKey = { owner: repo.owner, repo: repo.name, pr: prNumber };
52
55
  const seenMap = await loadSeenMap(stateKey);
@@ -54,7 +57,8 @@ export async function runCheck(opts) {
54
57
  const ruleSet = await loadRules(discoverRuleFiles(getEffectiveCwd()));
55
58
  const classifyIndex = buildClassifyIndex(ruleSet, batchData);
56
59
  const partition = partitionBatch(classifyIndex, batchData);
57
- const triaged = await attachUnseenCheckAnnotations(triagedBase, seenMap, prNumber);
60
+ const merged = await attachAndMergeCheckAnnotations({ passing, failing: triagedBase, skipped, filtered, ignored }, seenMap, prNumber);
61
+ const ignoredAnnotated = merged.ignored.filter((c) => (c.annotations?.length ?? 0) > 0);
58
62
  const minimizedCommentCandidates = batchData.comments.filter((c) => c.isMinimized && !partition.suppressedCommentIds.has(c.id));
59
63
  const visibleCommentClassification = classifyVisibleComments(batchData.comments.filter((c) => !partition.suppressedCommentIds.has(c.id)), seenMap, config.iterate.minimizeComments, botUsernames);
60
64
  const threadVisibility = classifyThreadVisibility(batchData.reviewThreads.filter((t) => !partition.suppressedThreadIds.has(t.id)), seenMap, botUsernames);
@@ -80,27 +84,29 @@ export async function runCheck(opts) {
80
84
  }
81
85
  const changesRequestedReviewVisibility = classifyChangesRequestedReviewsForDisplay(batchData.changesRequestedReviews.filter((r) => !partition.suppressedChangesRequestedIds.has(r.id)), seenMap, botUsernames);
82
86
  const approvedReviewVisibility = classifyReviewsForDisplay(batchData.approvedReviews, seenMap);
83
- await Promise.allSettled([
84
- ...firstLookComments.map((c) => markSeen(stateKey, c.id, c.body)),
85
- ...threadVisibility.toMarkSeen.map((t) => markSeen(stateKey, t.id, threadTranscriptBody(t))),
86
- ...visibleCommentClassification.toMarkSeen.map((c) => markSeen(stateKey, c.id, c.body)),
87
- ...[...firstLookSummaries, ...editedSummaries].map((r) => markSeen(stateKey, r.id, r.body)),
88
- ...changesRequestedReviewVisibility.toMarkSeen.map((r) => markSeen(stateKey, r.id, r.body)),
89
- ...approvedReviewVisibility.toMarkSeen.map((r) => markSeen(stateKey, r.id, r.body)),
90
- ...batchData.comments
91
- .filter((c) => partition.suppressedCommentIds.has(c.id))
92
- .map((c) => markSeen(stateKey, c.id, c.body)),
93
- ...batchData.reviewThreads
94
- .filter((t) => partition.suppressedThreadIds.has(t.id))
95
- .map((t) => markSeen(stateKey, t.id, threadTranscriptBody(t))),
96
- ...batchData.reviewSummaries
97
- .filter((r) => partition.suppressedReviewSummaryIds.has(r.id))
98
- .map((r) => markSeen(stateKey, r.id, r.body)),
99
- ...batchData.changesRequestedReviews
100
- .filter((r) => partition.suppressedChangesRequestedIds.has(r.id))
101
- .map((r) => markSeen(stateKey, r.id, r.body)),
102
- ]);
103
- await markReviewInlineThreadMarkers(stateKey, batchData.reviewThreads);
87
+ if (opts.persistSeen !== false) {
88
+ await Promise.allSettled([
89
+ ...firstLookComments.map((c) => markSeen(stateKey, c.id, c.body)),
90
+ ...threadVisibility.toMarkSeen.map((t) => markSeen(stateKey, t.id, threadTranscriptBody(t))),
91
+ ...visibleCommentClassification.toMarkSeen.map((c) => markSeen(stateKey, c.id, c.body)),
92
+ ...[...firstLookSummaries, ...editedSummaries].map((r) => markSeen(stateKey, r.id, r.body)),
93
+ ...changesRequestedReviewVisibility.toMarkSeen.map((r) => markSeen(stateKey, r.id, r.body)),
94
+ ...approvedReviewVisibility.toMarkSeen.map((r) => markSeen(stateKey, r.id, r.body)),
95
+ ...batchData.comments
96
+ .filter((c) => partition.suppressedCommentIds.has(c.id))
97
+ .map((c) => markSeen(stateKey, c.id, c.body)),
98
+ ...batchData.reviewThreads
99
+ .filter((t) => partition.suppressedThreadIds.has(t.id))
100
+ .map((t) => markSeen(stateKey, t.id, threadTranscriptBody(t))),
101
+ ...batchData.reviewSummaries
102
+ .filter((r) => partition.suppressedReviewSummaryIds.has(r.id))
103
+ .map((r) => markSeen(stateKey, r.id, r.body)),
104
+ ...batchData.changesRequestedReviews
105
+ .filter((r) => partition.suppressedChangesRequestedIds.has(r.id))
106
+ .map((r) => markSeen(stateKey, r.id, r.body)),
107
+ ]);
108
+ await markReviewInlineThreadMarkers(stateKey, batchData.reviewThreads);
109
+ }
104
110
  const { threadIds: ruleAutoResolveThreadIds, commentIds: ruleAutoResolveCommentIds, reviewSummaryIds: ruleAutoResolveReviewSummaryIds, } = await remainingRuleAutoResolveIds(partition, opts.autoMinimizeSuppressed);
105
111
  const changesRequestedReviews = changesRequestedReviewVisibility.visible;
106
112
  const changesRequestedReviewCount = batchData.changesRequestedReviews.filter((r) => !partition.suppressedChangesRequestedIds.has(r.id)).length;
@@ -121,11 +127,12 @@ export async function runCheck(opts) {
121
127
  baseBranch: batchData.baseRefName,
122
128
  mergeStatus,
123
129
  checks: {
124
- passing,
125
- failing: triaged,
130
+ passing: merged.passing,
131
+ failing: merged.failing,
126
132
  inProgress: inProgress,
127
- skipped,
128
- filtered,
133
+ skipped: merged.skipped,
134
+ filtered: merged.filtered,
135
+ ...(ignoredAnnotated.length > 0 && { ignored: ignoredAnnotated }),
129
136
  filteredNames: verdict.filteredNames,
130
137
  blockedByFilteredCheck,
131
138
  ...(verdict.ignoredNames.length > 0 && { ignoredNames: verdict.ignoredNames }),
@@ -1,9 +1,9 @@
1
1
  import { execFile as execFileCb } from "node:child_process";
2
2
  import { readFile } from "node:fs/promises";
3
- import { resolve } from "node:path";
3
+ import { relative, resolve } from "node:path";
4
4
  import { promisify } from "node:util";
5
5
  import { getRepoInfo, getCurrentPrNumber, getCurrentBranch } from "../github/client.mjs";
6
- import { fetchPrBatch } from "../github/batch.mjs";
6
+ import { fetchSuggestionThread } from "../github/suggestion-thread.mjs";
7
7
  import { parseSuggestion, isCommittableSuggestion } from "../suggestions/parse.mjs";
8
8
  import { buildUnifiedDiff } from "../suggestions/patch.mjs";
9
9
  import { EXIT, ShepherdError } from "../exit-codes.mjs";
@@ -28,7 +28,7 @@ export async function runCommitSuggestion(opts) {
28
28
  cwd: getExecutionCwd(),
29
29
  });
30
30
  const localHeadSha = localHeadOut.trim();
31
- const { data } = await fetchPrBatch(prNumber, repo);
31
+ const data = await fetchSuggestionThread(prNumber, repo, opts.threadId);
32
32
  if (!data.headRepoWithOwner) {
33
33
  throw new ShepherdError(`PR #${prNumber} head repository is unavailable (fork may have been deleted).`, EXIT.UNAVAILABLE);
34
34
  }
@@ -40,7 +40,7 @@ export async function runCommitSuggestion(opts) {
40
40
  throw new ShepherdError(`Local HEAD ${localHeadSha} does not match PR head ${data.headRefOid}. ` +
41
41
  `Pull/rebase "${data.headRefName}" to the latest PR head and try again.`, EXIT.UNAVAILABLE);
42
42
  }
43
- const thread = data.reviewThreads.find((t) => t.id === opts.threadId);
43
+ const thread = data.thread;
44
44
  if (!thread) {
45
45
  throw new ShepherdError(`Thread ${opts.threadId} not found on PR #${prNumber}.`, EXIT.UNAVAILABLE);
46
46
  }
@@ -73,7 +73,13 @@ export async function runCommitSuggestion(opts) {
73
73
  const startLine = thread.startLine ?? thread.line;
74
74
  const endLine = thread.line;
75
75
  const filePath = thread.path;
76
- const originalContent = await readFile(resolve(getEffectiveCwd(), filePath), "utf8");
76
+ const cwd = getEffectiveCwd();
77
+ const resolvedPath = resolve(cwd, filePath);
78
+ const rel = relative(cwd, resolvedPath);
79
+ if (rel.startsWith("..") || rel === "") {
80
+ throw new ShepherdError(`Thread ${opts.threadId} path escapes the working tree.`, EXIT.UNAVAILABLE);
81
+ }
82
+ const originalContent = await readFile(resolvedPath, "utf8");
77
83
  const patch = buildUnifiedDiff({
78
84
  path: filePath,
79
85
  originalContent,
@@ -8,7 +8,7 @@ import { buildResolveCommand } from "./classify.mjs";
8
8
  import { buildFixInstructions } from "./render.mjs";
9
9
  import { applyStallGuard } from "./stall.mjs";
10
10
  import { tryCancelRun, buildAutoCancelRunIdsWithOptions, buildInProgressRunIds, buildRunProtection, } from "./helpers.mjs";
11
- import { annotationMarkerBody } from "../check-annotations.mjs";
11
+ import { annotationMarkerBody, checksWithUnseenAnnotations } from "../check-annotations.mjs";
12
12
  import { threadTranscriptBody } from "../../threads/transcript.mjs";
13
13
  import { isHumanAuthor, isConfiguredBotAuthor } from "../../comments/authors.mjs";
14
14
  import { loadConfig } from "../../config/load.mjs";
@@ -30,6 +30,7 @@ function nextFixAttempts(stored, headSha, threads) {
30
30
  export async function handleFixCode(ctx) {
31
31
  const { base, report, opts, headSha, stallKey, prNumber, stallTimeoutSeconds, repoOwner, repoName, reviewSummaryIds, firstLookSummaries, editedSummaries, surfacedApprovals, botUsernames, ruleAutoResolveThreadIds, } = ctx;
32
32
  const failingChecks = report.checks.failing;
33
+ const annotatedExtra = checksWithUnseenAnnotations(report).filter((c) => c.category !== "failing");
33
34
  const { protectedRunIds, protectedRuns } = buildRunProtection([...failingChecks, ...report.checks.inProgress], opts.neverCancelRuns);
34
35
  const stored = await readFixAttempts({ owner: repoOwner, repo: repoName, pr: prNumber });
35
36
  const { threadAttempts, threadBodyHashes } = nextFixAttempts(stored, headSha, report.threads.actionable);
@@ -89,7 +90,11 @@ export async function handleFixCode(ctx) {
89
90
  const threads = report.threads.actionable.map(toAgentThread);
90
91
  const resolutionOnlyThreads = report.threads.resolutionOnly;
91
92
  const actionableComments = report.comments.actionable.map(toAgentComment);
92
- const checks = toAgentChecks(failingChecks);
93
+ const failingAgentChecks = toAgentChecks(failingChecks);
94
+ const checks = [
95
+ ...failingAgentChecks,
96
+ ...toAgentChecks(annotatedExtra).map((c) => ({ ...c, annotationOnly: true })),
97
+ ];
93
98
  const { changesRequestedReviews } = report;
94
99
  const hasConflicts = report.mergeStatus.status === "CONFLICTS";
95
100
  const isBehind = report.mergeStatus.status === "BEHIND";
@@ -98,7 +103,8 @@ export async function handleFixCode(ctx) {
98
103
  // summary-only iterations have no path to a push, so listing runs would prompt
99
104
  // unnecessary cancellation.
100
105
  const pushLikely = threads.length > 0 ||
101
- checks.length > 0 ||
106
+ failingAgentChecks.length > 0 ||
107
+ annotatedExtra.length > 0 ||
102
108
  hasConflicts ||
103
109
  changesRequestedReviews.length > 0 ||
104
110
  actionableComments.length > 0;
@@ -110,13 +116,14 @@ export async function handleFixCode(ctx) {
110
116
  : [];
111
117
  const commentMinimizeIds = report.comments.minimizeIds ?? actionableComments.map((c) => c.id);
112
118
  const allCommentIds = [...commentMinimizeIds, ...reviewSummaryIds];
113
- const { resolveCommand, resolveOnlyCommand } = buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, changesRequestedReviews, checks, prNumber, botUsernames, ruleAutoResolveThreadIds);
119
+ const { resolveCommand, resolveOnlyCommand } = buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, changesRequestedReviews, failingAgentChecks, prNumber, botUsernames, ruleAutoResolveThreadIds);
114
120
  // Safety: if the base branch is unknown, escalate when a push is plausible — the agent
115
121
  // would need the correct base to rebase safely. This is a conservative guard, not a
116
122
  // prediction that the agent *will* push. Intentionally broader than `pushLikely` above:
117
123
  // resolution-only threads also need a known base in case the agent does push.
118
124
  const pushIsPlausible = threads.length > 0 ||
119
- checks.length > 0 ||
125
+ failingAgentChecks.length > 0 ||
126
+ annotatedExtra.length > 0 ||
120
127
  hasConflicts ||
121
128
  changesRequestedReviews.length > 0 ||
122
129
  actionableComments.length > 0 ||
@@ -166,7 +173,7 @@ export async function handleFixCode(ctx) {
166
173
  cancelled,
167
174
  };
168
175
  const result = await applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, prospectiveResult, report, reviewSummaryIds);
169
- if (result.action === "fix_code") {
176
+ if (result.action === "fix_code" && opts.persistSeen !== false) {
170
177
  await Promise.allSettled(result.fix.checks.flatMap((ch) => (ch.annotations ?? []).map((a) => markSeen(stallKey, a.id, annotationMarkerBody(a)))));
171
178
  }
172
179
  return result;
@@ -11,3 +11,4 @@ export declare function buildActiveChecks(report: ShepherdReport): ActiveCheck[]
11
11
  export declare function tryCancelRun(runId: string, owner: string, repo: string): Promise<string | null>;
12
12
  export declare function getCurrentHeadSha(): Promise<string | null>;
13
13
  export declare function buildWaitLog(base: IterateResultBase): string;
14
+ export declare function blockedCancelNote(base: IterateResultBase): string;
@@ -2,6 +2,7 @@ import { execFile as execFileCb } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
  import { rest } from "../../github/http.mjs";
4
4
  import { getExecutionCwd } from "../../execution-context.mjs";
5
+ import { blockedReasonFromRequirements } from "../../merge-status/requirements-format.mjs";
5
6
  const execFile = promisify(execFileCb);
6
7
  export { buildAutoCancelRunIdsWithOptions, buildInProgressRunIds, buildRunProtection, } from "./reruns.mjs";
7
8
  export function buildSummary(report) {
@@ -40,6 +41,7 @@ export function buildTerminalCancelResult(report) {
40
41
  summary: buildSummary(report),
41
42
  baseBranch: report.baseBranch,
42
43
  branchProtection: report.branchProtection,
44
+ mergeRequirements: report.mergeStatus.mergeRequirements,
43
45
  checks: buildRelevantChecks(report),
44
46
  inProgressChecks: buildActiveChecks(report),
45
47
  ...buildSuppressedCheckFields(report),
@@ -134,14 +136,15 @@ export function buildWaitLog(base) {
134
136
  .join(", ")}`);
135
137
  }
136
138
  switch (base.mergeStatus) {
137
- case "BLOCKED":
138
- if (base.reviewDecision === "REVIEW_REQUIRED")
139
- parts.push("awaiting human review");
140
- else if (base.reviewDecision === "APPROVED")
141
- parts.push("awaiting additional approvals");
142
- else
139
+ case "BLOCKED": {
140
+ const fromRules = blockedReasonFromRequirements(base.mergeRequirements);
141
+ if (fromRules)
142
+ parts.push(fromRules);
143
+ else if (!base.blockingBotReviewInProgress) {
143
144
  parts.push("awaiting human review or branch protection");
145
+ }
144
146
  break;
147
+ }
145
148
  case "BEHIND":
146
149
  parts.push("branch is behind base");
147
150
  break;
@@ -157,3 +160,9 @@ export function buildWaitLog(base) {
157
160
  }
158
161
  return parts.join(" — ");
159
162
  }
163
+ export function blockedCancelNote(base) {
164
+ if (base.mergeStatus !== "BLOCKED")
165
+ return "has been ready for review";
166
+ const fromRules = blockedReasonFromRequirements(base.mergeRequirements);
167
+ return fromRules ? `is ${fromRules}` : "is awaiting human review or branch protection resolution";
168
+ }
@@ -5,13 +5,14 @@ import { graphql } from "../../github/http.mjs";
5
5
  import { MARK_PR_READY_MUTATION } from "../../github/queries.mjs";
6
6
  import { loadConfig } from "../../config/load.mjs";
7
7
  import { EXIT, ShepherdError } from "../../exit-codes.mjs";
8
- import { getCurrentHeadSha, buildSummary, buildRelevantChecks, buildActiveChecks, buildWaitLog, buildSuppressedCheckFields, buildTerminalCancelResult, } from "./helpers.mjs";
8
+ import { getCurrentHeadSha, buildSummary, buildRelevantChecks, buildActiveChecks, buildWaitLog, buildSuppressedCheckFields, buildTerminalCancelResult, blockedCancelNote, } from "./helpers.mjs";
9
9
  import { classifyReviewSummaries } from "./classify.mjs";
10
10
  import { applyStallGuard } from "./stall.mjs";
11
11
  import { clearStallState } from "../../state/iterate-stall.mjs";
12
12
  import { handleFixCode } from "./fix-code.mjs";
13
13
  import { normalizeBotUsernames } from "../../comments/authors.mjs";
14
14
  import { autoMinimizeComments } from "../../comments/resolve.mjs";
15
+ import { checksWithUnseenAnnotations } from "../check-annotations.mjs";
15
16
  export async function runIterate(opts) {
16
17
  const config = loadConfig();
17
18
  const botUsernames = normalizeBotUsernames(config.botUsernames);
@@ -69,6 +70,7 @@ export async function runIterate(opts) {
69
70
  report.comments.firstLook.length > 0 ||
70
71
  report.changesRequestedReviews.length > 0 ||
71
72
  report.checks.failing.length > 0 ||
73
+ checksWithUnseenAnnotations(report).length > 0 ||
72
74
  report.mergeStatus.status === "CONFLICTS" ||
73
75
  reviewSummaryIds.length > 0 ||
74
76
  firstLookSummaries.length > 0 ||
@@ -90,6 +92,7 @@ export async function runIterate(opts) {
90
92
  summary: buildSummary(report),
91
93
  baseBranch: report.baseBranch,
92
94
  branchProtection: report.branchProtection,
95
+ mergeRequirements: report.mergeStatus.mergeRequirements,
93
96
  checks: buildRelevantChecks(report),
94
97
  inProgressChecks: buildActiveChecks(report),
95
98
  ...buildSuppressedCheckFields(report),
@@ -97,15 +100,7 @@ export async function runIterate(opts) {
97
100
  };
98
101
  if (readyState.shouldCancel) {
99
102
  await clearStallState(stallKey);
100
- let cancelNote;
101
- if (base.mergeStatus !== "BLOCKED")
102
- cancelNote = "has been ready for review";
103
- else if (base.reviewDecision === "REVIEW_REQUIRED")
104
- cancelNote = "is awaiting human review";
105
- else if (base.reviewDecision === "APPROVED")
106
- cancelNote = "is awaiting additional approvals";
107
- else
108
- cancelNote = "is awaiting human review or branch protection resolution";
103
+ const cancelNote = blockedCancelNote(base);
109
104
  return {
110
105
  ...base,
111
106
  action: "cancel",
@@ -1,6 +1,7 @@
1
1
  import { renderShellCommand } from "../../cli/runner.mjs";
2
2
  import { buildFailingCheckInstructions, buildCrStaleClause, buildBehindBaseHintInstruction, buildResolveCommandInstruction, } from "./check-instructions.mjs";
3
3
  import { SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE, SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEM_HEADINGS, buildShepherdJournalInstruction, } from "../shepherd-journal.mjs";
4
+ import { isFailingAgentCheck } from "../../checks/conclusions.mjs";
4
5
  import { buildCommitSuggestionInstruction } from "../commit-suggestion-instruction.mjs";
5
6
  const FIX_INSTRUCTION_STOP = "Stop this iteration — if you pushed new commits, CI needs time before the next tick; otherwise stop before the next tick.";
6
7
  /** Render a resolve command as a shell snippet. Appends `--require-sha "$HEAD_SHA"` when set. */
@@ -13,8 +14,11 @@ export function renderResolveCommand(rc) {
13
14
  export function buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseBranch, resolveCommand, hasConflicts, prNumber, cancelledCount, firstLookThreads = [], firstLookComments = [], firstLookSummaries = [], editedSummaries = [], inProgressRunIds = [], resolutionOnlyThreads = [], resolveOnlyCommand, behindBaseHint = "", // iterate.behindBaseHint — see buildBehindBaseHintInstruction
14
15
  isBehind = false) {
15
16
  const instructions = [];
17
+ const failingChecks = checks.filter((c) => isFailingAgentCheck(c));
18
+ const hasAnnotations = checks.some((c) => (c.annotations?.length ?? 0) > 0);
16
19
  const hasNonConflictHints = threads.length > 0 ||
17
- checks.length > 0 ||
20
+ failingChecks.length > 0 ||
21
+ hasAnnotations ||
18
22
  changesRequestedReviews.length > 0 ||
19
23
  actionableComments.length > 0;
20
24
  // Leading decision or mandatory instruction depending on what actionable items exist.
@@ -24,9 +28,9 @@ isBehind = false) {
24
28
  actionableSections.push("`## Review threads`");
25
29
  if (actionableComments.length > 0)
26
30
  actionableSections.push("`## Actionable comments`");
27
- if (checks.length > 0)
31
+ if (failingChecks.length > 0)
28
32
  actionableSections.push("`## Failing checks`");
29
- if (checks.some((c) => (c.annotations?.length ?? 0) > 0)) {
33
+ if (hasAnnotations) {
30
34
  actionableSections.push("`## Check annotations`");
31
35
  }
32
36
  if (changesRequestedReviews.length > 0)
@@ -68,8 +72,8 @@ isBehind = false) {
68
72
  if (resolutionOnlyThreads.length > 0) {
69
73
  instructions.push(`Review the threads under \`## Review threads to resolve\`. Human-authored threads are replied to by the \`apply review:\` command shown below; Shepherd does not resolve them. Bot/non-human threads are included in \`--resolve-thread-ids\`.`);
70
74
  }
71
- instructions.push(...buildFailingCheckInstructions(checks));
72
- if (checks.some((c) => (c.annotations?.length ?? 0) > 0)) {
75
+ instructions.push(...buildFailingCheckInstructions(failingChecks));
76
+ if (hasAnnotations) {
73
77
  instructions.push(`For each item under \`## Check annotations\`: inspect the referenced file range and decide whether the annotation requires a code change. These annotations are surfaced once per PR and do not need any resolve/minimize mutation.`);
74
78
  }
75
79
  if (changesRequestedReviews.length > 0) {
@@ -1,6 +1,7 @@
1
1
  import { readStallState, writeStallState } from "../../state/iterate-stall.mjs";
2
2
  import { toAgentThread, toAgentComment, toAgentStalledCheck } from "../../reporters/agent.mjs";
3
3
  import { buildEscalateSuggestion, buildEscalateHumanMessage, formatDurationApprox, } from "./escalate.mjs";
4
+ import { checksWithUnseenAnnotations } from "../check-annotations.mjs";
4
5
  function computeStallFingerprint(action, headSha, base, report, reviewSummaryIds) {
5
6
  const checks = [
6
7
  ...report.checks.failing.map((f) => `failing:${f.name}:${f.conclusion}`),
@@ -13,6 +14,9 @@ function computeStallFingerprint(action, headSha, base, report, reviewSummaryIds
13
14
  const reviews = report.changesRequestedReviews.map((r) => r.id).sort();
14
15
  const summaries = [...reviewSummaryIds].sort();
15
16
  const ruleAutoResolveSummaries = (report.ruleAutoResolveReviewSummaryIds ?? []).sort((a, b) => a.localeCompare(b));
17
+ const annotations = checksWithUnseenAnnotations(report)
18
+ .flatMap((c) => (c.annotations ?? []).map((a) => a.id))
19
+ .sort((a, b) => a.localeCompare(b));
16
20
  return JSON.stringify({
17
21
  action,
18
22
  headSha,
@@ -28,6 +32,7 @@ function computeStallFingerprint(action, headSha, base, report, reviewSummaryIds
28
32
  reviews,
29
33
  summaries,
30
34
  ruleAutoResolveSummaries,
35
+ annotations,
31
36
  });
32
37
  }
33
38
  export async function applyStallGuard(stallKey, stallTimeoutSeconds, headSha, base, prNumber, prospectiveResult, report, reviewSummaryIds) {
@@ -2,6 +2,8 @@ import type { IterateCommandOptions, IterateResult } from "../types.mts";
2
2
  interface PollCommandOptions extends IterateCommandOptions {
3
3
  intervalSeconds: number;
4
4
  timeoutSeconds: number;
5
+ /** Settle window after first FIX_CODE before returning. Default 60. 0 disables. */
6
+ debounceSeconds?: number;
5
7
  quietStatus?: boolean;
6
8
  untilTerminal?: boolean;
7
9
  }
@@ -1,5 +1,6 @@
1
1
  import { runIterate } from "./iterate/index.mjs";
2
2
  import { sleep } from "../util/sleep.mjs";
3
+ const DEFAULT_POLL_DEBOUNCE_SECONDS = 60;
3
4
  function writeTickProgress(tick, elapsedSeconds, sleepSeconds, verbose) {
4
5
  if (verbose) {
5
6
  process.stderr.write(`[poll tick ${tick} / +${elapsedSeconds}s] WAIT — sleeping ${sleepSeconds}s\n`);
@@ -54,11 +55,18 @@ function writeWaitProgress(opts) {
54
55
  }
55
56
  return signature;
56
57
  }
58
+ function writeDebounceProgress(tick, elapsedMs, remainingMs) {
59
+ const elapsedSeconds = Math.round(elapsedMs / 1000);
60
+ const remainingSeconds = Math.round(remainingMs / 1000);
61
+ process.stderr.write(`[poll tick ${tick} / +${elapsedSeconds}s] FIX_CODE — debounce ${remainingSeconds}s remaining\n`);
62
+ }
57
63
  /** @deprecated Hidden implementation for the legacy `poll` alias. */
58
64
  export async function runPoll(opts) {
59
- const { intervalSeconds, timeoutSeconds, quietStatus: quietStatusOpt, untilTerminal: untilTerminalOpt, ...iterateOpts } = opts;
65
+ const { intervalSeconds, timeoutSeconds, debounceSeconds: debounceSecondsOpt, quietStatus: quietStatusOpt, untilTerminal: untilTerminalOpt, ...iterateOpts } = opts;
60
66
  const intervalMs = Math.min(intervalSeconds * 1000, MAX_TIMER_MS);
61
67
  const timeoutMs = Math.min(timeoutSeconds * 1000, MAX_TIMER_MS);
68
+ const debounceSeconds = debounceSecondsOpt ?? DEFAULT_POLL_DEBOUNCE_SECONDS;
69
+ const debounceMs = Math.min(debounceSeconds * 1000, MAX_TIMER_MS);
62
70
  const start = Date.now();
63
71
  let tick = 0;
64
72
  let lastResult;
@@ -73,27 +81,30 @@ export async function runPoll(opts) {
73
81
  // terminal CANCEL/merged result. Pin the PR resolved by the first tick so later ticks target it
74
82
  // directly and never re-run branch discovery.
75
83
  let prNumber = opts.prNumber;
84
+ let debounceUntil = null;
76
85
  while (true) {
77
86
  tick += 1;
78
- lastResult = await runIterate({ ...iterateOpts, prNumber });
87
+ const pastDebounce = debounceUntil !== null && Date.now() >= debounceUntil;
88
+ lastResult = await runIterate({
89
+ ...iterateOpts,
90
+ prNumber,
91
+ persistSeen: debounceSeconds === 0 || pastDebounce,
92
+ });
79
93
  prNumber ??= lastResult.pr;
80
- if (lastResult.action !== "wait" && !(untilTerminal && lastResult.action === "mark_ready")) {
81
- break;
82
- }
83
- const elapsedMs = Date.now() - start;
84
- if (!untilTerminal) {
85
- const remainingMs = timeoutMs - elapsedMs;
86
- if (remainingMs <= 0)
87
- break;
88
- if (remainingMs + TIMER_DRIFT_TOLERANCE_MS < intervalMs)
89
- break;
90
- }
91
- const nextSleepMs = intervalMs;
92
- if (lastResult.action === "wait") {
94
+ if (lastResult.action === "wait" && !pastDebounce) {
95
+ debounceUntil = null;
96
+ const elapsedMs = Date.now() - start;
97
+ if (!untilTerminal) {
98
+ const remainingMs = timeoutMs - elapsedMs;
99
+ if (remainingMs <= 0)
100
+ break;
101
+ if (remainingMs + TIMER_DRIFT_TOLERANCE_MS < intervalMs)
102
+ break;
103
+ }
93
104
  lastWaitSignature = writeWaitProgress({
94
105
  tick,
95
106
  elapsedMs,
96
- sleepMs: nextSleepMs,
107
+ sleepMs: intervalMs,
97
108
  result: lastResult,
98
109
  quietStatus,
99
110
  verbose,
@@ -101,8 +112,31 @@ export async function runPoll(opts) {
101
112
  });
102
113
  if (!quietStatus && !verbose)
103
114
  dotsPrinted = true;
115
+ await sleep(intervalMs);
116
+ continue;
117
+ }
118
+ if (untilTerminal && lastResult.action === "mark_ready" && !pastDebounce) {
119
+ debounceUntil = null;
120
+ await sleep(intervalMs);
121
+ continue;
122
+ }
123
+ if (lastResult.action === "fix_code" && debounceSeconds > 0 && !pastDebounce) {
124
+ debounceUntil ??= Date.now() + debounceMs;
125
+ const remainingMs = debounceUntil - Date.now();
126
+ if (remainingMs > 0) {
127
+ if (dotsPrinted) {
128
+ process.stderr.write("\n");
129
+ dotsPrinted = false;
130
+ }
131
+ writeDebounceProgress(tick, Date.now() - start, remainingMs);
132
+ await sleep(Math.min(intervalMs, remainingMs));
133
+ }
134
+ // Always continue so a tick that *started* before debounceUntil but *finished*
135
+ // after it cannot return with persistSeen: false. The next loop iteration
136
+ // sees pastDebounce and persists.
137
+ continue;
104
138
  }
105
- await sleep(nextSleepMs);
139
+ break;
106
140
  }
107
141
  if (dotsPrinted)
108
142
  process.stderr.write("\n");
@@ -6,9 +6,8 @@
6
6
  * `readyDelaySeconds` consecutively. Any not-READY result resets the timer.
7
7
  */
8
8
  import { readFile, writeFile, mkdir, unlink } from "node:fs/promises";
9
- import { join, dirname } from "node:path";
10
- import { SAFE_SEGMENT } from "../util/path-segment.mjs";
11
- import { resolveStateBase } from "../state/base.mjs";
9
+ import { dirname } from "node:path";
10
+ import { resolvePrStatePath } from "../state/base.mjs";
12
11
  /**
13
12
  * Update the ready-delay state machine and return the current decision.
14
13
  *
@@ -57,16 +56,7 @@ export async function updateReadyDelay(prNumber, isReady, readyDelaySeconds, own
57
56
  // Helpers
58
57
  // ---------------------------------------------------------------------------
59
58
  function readySincePath(pr, owner, repo) {
60
- for (const [field, value] of [
61
- ["owner", owner],
62
- ["repo", repo],
63
- ]) {
64
- if (!SAFE_SEGMENT.test(value)) {
65
- throw new Error(`Invalid path segment "${field}": ${value}`);
66
- }
67
- }
68
- const base = resolveStateBase();
69
- return join(base, `${owner}-${repo}`, String(pr), "ready-since.txt");
59
+ return resolvePrStatePath({ owner, repo, pr }, "ready-since.txt");
70
60
  }
71
61
  async function safeUnlink(path) {
72
62
  try {