pr-shepherd 0.5.2 → 0.7.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.
@@ -27,6 +27,8 @@ import { runCheck } from "./check.mjs";
27
27
  import { triageFailingChecks } from "../checks/triage.mjs";
28
28
  import { updateReadyDelay } from "./ready-delay.mjs";
29
29
  import { getCurrentPrNumber } from "../github/client.mjs";
30
+ import { rest, graphql } from "../github/http.mjs";
31
+ import { MARK_PR_READY_MUTATION } from "../github/queries.mjs";
30
32
  import { readFixAttempts, writeFixAttempts } from "../cache/fix-attempts.mjs";
31
33
  import { toAgentThread, toAgentComment, toAgentChecks } from "../reporters/agent.mjs";
32
34
  import { loadConfig } from "../config/load.mjs";
@@ -61,6 +63,8 @@ export async function runIterate(opts) {
61
63
  shouldCancel: false,
62
64
  remainingSeconds: readyDelaySeconds,
63
65
  summary: { passing: 0, skipped: 0, filtered: 0, inProgress: 0 },
66
+ baseBranch: "",
67
+ log: "SKIP: CI still starting — waiting for first check to appear",
64
68
  };
65
69
  }
66
70
  // Step 2: Sweep — fetch CI + comments + merge status, auto-resolve outdated.
@@ -72,6 +76,7 @@ export async function runIterate(opts) {
72
76
  });
73
77
  // Step 2.5: Cancel if PR is merged or closed — no longer actionable.
74
78
  if (report.mergeStatus.state !== "OPEN") {
79
+ const state = report.mergeStatus.state.toLowerCase();
75
80
  return {
76
81
  pr: report.pr,
77
82
  repo: report.repo,
@@ -83,7 +88,9 @@ export async function runIterate(opts) {
83
88
  remainingSeconds: 0,
84
89
  state: report.mergeStatus.state,
85
90
  summary: buildSummary(report),
91
+ baseBranch: report.baseBranch,
86
92
  action: "cancel",
93
+ log: `CANCEL: PR #${report.pr} is ${state} — stopping monitor`,
87
94
  };
88
95
  }
89
96
  // Step 3: Ready-delay state machine.
@@ -104,14 +111,22 @@ export async function runIterate(opts) {
104
111
  shouldCancel: readyState.shouldCancel,
105
112
  remainingSeconds: readyState.remainingSeconds,
106
113
  summary: buildSummary(report),
114
+ baseBranch: report.baseBranch,
107
115
  };
108
116
  // Step 3 cont.: cancel if ready-delay elapsed.
109
117
  if (readyState.shouldCancel) {
110
- return { ...base, action: "cancel" };
118
+ return {
119
+ ...base,
120
+ action: "cancel",
121
+ log: `CANCEL: PR #${base.pr} has been ready for review — ready-delay elapsed, stopping monitor`,
122
+ };
111
123
  }
112
124
  // Triage failing checks now that we know we need failureKind for steps 4–6.
113
125
  if (report.checks.failing.length > 0) {
114
- const triaged = await triageFailingChecks(report.checks.failing);
126
+ const triaged = await triageFailingChecks(report.checks.failing, {
127
+ owner: repoOwner,
128
+ name: repoName,
129
+ });
115
130
  report = { ...report, checks: { ...report.checks, failing: triaged } };
116
131
  }
117
132
  // Step 4: Actionable work — fix comments, review requests, CI failures, and merge
@@ -134,16 +149,20 @@ export async function runIterate(opts) {
134
149
  // Escalation checks — surface ambiguous situations instead of looping forever.
135
150
  const escalateTriggers = checkEscalateTriggers(report.threads.actionable, report.comments.actionable, report.changesRequestedReviews, actionableChecks, attempts.threadAttempts, report.mergeStatus.status === "CONFLICTS");
136
151
  if (escalateTriggers.triggers.length > 0) {
152
+ const escalateBase = {
153
+ triggers: escalateTriggers.triggers,
154
+ unresolvedThreads: report.threads.actionable.map(toAgentThread),
155
+ ambiguousComments: report.comments.actionable.map(toAgentComment),
156
+ changesRequestedReviews: report.changesRequestedReviews,
157
+ attemptHistory: escalateTriggers.thrashHistory,
158
+ suggestion: buildEscalateSuggestion(escalateTriggers.triggers),
159
+ };
137
160
  return {
138
161
  ...base,
139
162
  action: "escalate",
140
163
  escalate: {
141
- triggers: escalateTriggers.triggers,
142
- unresolvedThreads: report.threads.actionable.map(toAgentThread),
143
- ambiguousComments: report.comments.actionable.map(toAgentComment),
144
- changesRequestedReviews: report.changesRequestedReviews,
145
- attemptHistory: escalateTriggers.thrashHistory,
146
- suggestion: buildEscalateSuggestion(escalateTriggers.triggers),
164
+ ...escalateBase,
165
+ humanMessage: buildEscalateHumanMessage(escalateBase, prNumber),
147
166
  },
148
167
  };
149
168
  }
@@ -158,17 +177,51 @@ export async function runIterate(opts) {
158
177
  const uniqueRunIds = [
159
178
  ...new Set(actionableChecks.map((c) => c.runId).filter((id) => id !== null)),
160
179
  ];
161
- const results = await Promise.all(uniqueRunIds.map((id) => tryCancelRun(id)));
180
+ const results = await Promise.all(uniqueRunIds.map((id) => tryCancelRun(id, repoOwner, repoName)));
162
181
  cancelled = results.filter((id) => id !== null);
163
182
  }
183
+ const baseLookup = validateBaseBranch(report.baseBranch);
184
+ const threads = report.threads.actionable.map(toAgentThread);
185
+ const { actionable: actionableComments, noiseIds: noiseCommentIds } = classifyComments(report.comments.actionable.map(toAgentComment));
186
+ const checks = toAgentChecks(actionableChecks);
187
+ const { changesRequestedReviews } = report;
188
+ const allCommentIds = [...actionableComments.map((c) => c.id), ...noiseCommentIds];
189
+ const resolveCommand = buildResolveCommand(threads, actionableComments, allCommentIds, changesRequestedReviews, checks, prNumber);
190
+ const hasConflicts = report.mergeStatus.status === "CONFLICTS";
191
+ // Guard: if the emitted flow requires a push (code fixes or conflict
192
+ // resolution rebase) but we could not confirm the PR's base branch, refuse
193
+ // to emit fix_code — a wrong-base rebase would rewrite history onto the
194
+ // wrong target. Escalate for human direction.
195
+ if (baseLookup.isFallback && (resolveCommand.requiresHeadSha || hasConflicts)) {
196
+ const fallbackEscalateBase = {
197
+ triggers: ["base-branch-unknown"],
198
+ unresolvedThreads: threads,
199
+ ambiguousComments: actionableComments,
200
+ changesRequestedReviews,
201
+ suggestion: buildEscalateSuggestion(["base-branch-unknown"], baseLookup.failureReason),
202
+ };
203
+ return {
204
+ ...base,
205
+ action: "escalate",
206
+ escalate: {
207
+ ...fallbackEscalateBase,
208
+ humanMessage: buildEscalateHumanMessage(fallbackEscalateBase, prNumber),
209
+ },
210
+ };
211
+ }
212
+ const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prNumber);
164
213
  return {
165
214
  ...base,
215
+ baseBranch: baseLookup.branch,
166
216
  action: "fix_code",
167
217
  fix: {
168
- threads: report.threads.actionable.map(toAgentThread),
169
- comments: report.comments.actionable.map(toAgentComment),
170
- checks: toAgentChecks(actionableChecks),
171
- changesRequestedReviews: report.changesRequestedReviews,
218
+ threads,
219
+ actionableComments,
220
+ noiseCommentIds,
221
+ checks,
222
+ changesRequestedReviews,
223
+ resolveCommand,
224
+ instructions,
172
225
  },
173
226
  cancelled,
174
227
  };
@@ -176,17 +229,63 @@ export async function runIterate(opts) {
176
229
  // Step 5: Transient failures (timeout / infrastructure) — no actionable work, no conflicts.
177
230
  const transientChecks = report.checks.failing.filter((f) => f.failureKind === "timeout" || f.failureKind === "infrastructure");
178
231
  if (transientChecks.length > 0 && !opts.noAutoRerun) {
179
- // Deduplicate runIds — multiple failed steps can share the same runId.
180
- const uniqueRunIds = [
181
- ...new Set(transientChecks.map((c) => c.runId).filter((id) => id !== null)),
182
- ];
183
- await Promise.all(uniqueRunIds.map((runId) => runGhCommand(["run", "rerun", runId, "--failed"])));
184
- return { ...base, action: "rerun_ci", reran: uniqueRunIds };
232
+ // Group checks by runId — multiple failed steps can share one run.
233
+ const runMap = new Map();
234
+ for (const c of transientChecks) {
235
+ if (c.runId === null)
236
+ continue;
237
+ const existing = runMap.get(c.runId);
238
+ if (existing) {
239
+ existing.checkNames.push(c.name);
240
+ }
241
+ else {
242
+ runMap.set(c.runId, {
243
+ runId: c.runId,
244
+ checkNames: [c.name],
245
+ failureKind: c.failureKind,
246
+ });
247
+ }
248
+ }
249
+ const reran = [...runMap.values()];
250
+ await Promise.all(reran.map(({ runId }) => rest("POST", `/repos/${repoOwner}/${repoName}/actions/runs/${runId}/rerun-failed-jobs`)));
251
+ const runSummaries = reran.map(({ runId, checkNames, failureKind }) => `${runId} (${checkNames.join(", ")} — ${failureKind})`);
252
+ return {
253
+ ...base,
254
+ action: "rerun_ci",
255
+ reran,
256
+ log: `RERAN ${reran.length} CI run${reran.length === 1 ? "" : "s"}: ${runSummaries.join(", ")}`,
257
+ };
185
258
  }
186
259
  // Step 6: Flaky + behind — rebase needed.
187
260
  const hasFlaky = report.checks.failing.some((f) => f.failureKind === "flaky");
188
261
  if (hasFlaky && report.mergeStatus.status === "BEHIND" && config.actions.autoRebase) {
189
- return { ...base, action: "rebase" };
262
+ const baseLookup = validateBaseBranch(report.baseBranch);
263
+ if (baseLookup.isFallback) {
264
+ const fallbackEscalateBase = {
265
+ triggers: ["base-branch-unknown"],
266
+ unresolvedThreads: [],
267
+ ambiguousComments: [],
268
+ changesRequestedReviews: [],
269
+ suggestion: buildEscalateSuggestion(["base-branch-unknown"], baseLookup.failureReason),
270
+ };
271
+ return {
272
+ ...base,
273
+ action: "escalate",
274
+ escalate: {
275
+ ...fallbackEscalateBase,
276
+ humanMessage: buildEscalateHumanMessage(fallbackEscalateBase, prNumber),
277
+ },
278
+ };
279
+ }
280
+ return {
281
+ ...base,
282
+ baseBranch: baseLookup.branch,
283
+ action: "rebase",
284
+ rebase: {
285
+ reason: `Branch is behind ${baseLookup.branch} — rebasing to pick up latest changes and clear flaky failures`,
286
+ shellScript: buildRebaseShellScript(baseLookup.branch),
287
+ },
288
+ };
190
289
  }
191
290
  // Step 7: Mark ready for review.
192
291
  // Draft PRs often report mergeStateStatus === 'DRAFT' rather than 'CLEAN' until
@@ -199,11 +298,20 @@ export async function runIterate(opts) {
199
298
  !readyState.shouldCancel &&
200
299
  report.mergeStatus.isDraft;
201
300
  if (canMarkReady && !opts.noAutoMarkReady && config.actions.autoMarkReady) {
202
- await runGhCommand(["pr", "ready", String(report.pr)]);
203
- return { ...base, action: "mark_ready", markedReady: true };
301
+ await graphql(MARK_PR_READY_MUTATION, { pullRequestId: report.nodeId });
302
+ return {
303
+ ...base,
304
+ action: "mark_ready",
305
+ markedReady: true,
306
+ log: `MARKED READY: PR #${report.pr} converted from draft to ready for review`,
307
+ };
204
308
  }
205
309
  // Step 8: Nothing to do.
206
- return { ...base, action: "wait" };
310
+ return {
311
+ ...base,
312
+ action: "wait",
313
+ log: buildWaitLog(base),
314
+ };
207
315
  }
208
316
  // ---------------------------------------------------------------------------
209
317
  // Helpers
@@ -225,27 +333,18 @@ async function getLastCommitTime() {
225
333
  return 0;
226
334
  }
227
335
  }
228
- async function runGhCommand(args) {
229
- try {
230
- await execFile("gh", args);
231
- }
232
- catch (err) {
233
- const msg = err instanceof Error ? err.message : String(err);
234
- throw new Error(`gh ${args.join(" ")} failed: ${msg}`, { cause: err });
235
- }
236
- }
237
336
  // Best-effort: cancelling a completed run is a no-op, not an error.
238
- async function tryCancelRun(runId) {
337
+ async function tryCancelRun(runId, owner, repo) {
239
338
  try {
240
- await execFile("gh", ["run", "cancel", runId]);
339
+ await rest("POST", `/repos/${owner}/${repo}/actions/runs/${runId}/cancel`);
241
340
  return runId;
242
341
  }
243
342
  catch (err) {
244
343
  const msg = err instanceof Error ? err.message : String(err);
245
- // gh returns this when the run reached a terminal state — expected, not worth logging.
246
- if (/already completed|cannot cancel a workflow run that is completed/i.test(msg))
344
+ // GitHub returns 409 when the run reached a terminal state — expected, not worth logging.
345
+ if (/409|already completed|cannot cancel a workflow run that is completed/i.test(msg))
247
346
  return null;
248
- process.stderr.write(`pr-shepherd: gh run cancel ${runId} failed (ignored): ${msg}\n`);
347
+ process.stderr.write(`pr-shepherd: cancel run ${runId} failed (ignored): ${msg}\n`);
249
348
  return null;
250
349
  }
251
350
  }
@@ -287,7 +386,235 @@ function checkEscalateTriggers(actionableThreads, actionableComments, changesReq
287
386
  : undefined,
288
387
  };
289
388
  }
290
- function buildEscalateSuggestion(triggers) {
389
+ /**
390
+ * Validate the base branch name from the GraphQL batch (`report.baseBranch`)
391
+ * and fall back safely if it's missing/unsafe. The branch is interpolated into
392
+ * shell commands by `buildRebaseShellScript` and `buildFixInstructions`, so we
393
+ * reject anything outside `[A-Za-z0-9._/-]` to prevent shell injection.
394
+ *
395
+ * Previously a separate `gh pr view --json baseRefName` subprocess — eliminated
396
+ * per review feedback since the batch GraphQL query now returns it directly.
397
+ */
398
+ function validateBaseBranch(raw) {
399
+ const trimmed = raw.trim();
400
+ if (trimmed === "") {
401
+ return {
402
+ branch: "main",
403
+ isFallback: true,
404
+ failureReason: "GraphQL batch returned an empty base branch name",
405
+ };
406
+ }
407
+ if (!/^[A-Za-z0-9._/-]+$/.test(trimmed)) {
408
+ return {
409
+ branch: "main",
410
+ isFallback: true,
411
+ failureReason: `base branch ${JSON.stringify(trimmed)} contains unsafe characters`,
412
+ };
413
+ }
414
+ return { branch: trimmed, isFallback: false };
415
+ }
416
+ function buildRebaseShellScript(baseBranch) {
417
+ return [
418
+ `if ! git diff --quiet || ! git diff --cached --quiet; then`,
419
+ ` echo "SKIP rebase: dirty worktree (uncommitted changes present)"`,
420
+ ` exit 1`,
421
+ `fi`,
422
+ `git fetch origin && git rebase origin/${baseBranch} && git push --force-with-lease`,
423
+ ].join("\n");
424
+ }
425
+ // Patterns that indicate a comment is bot-generated noise rather than actionable feedback.
426
+ // Conservative: only match explicit known patterns to avoid accidentally suppressing real reviews.
427
+ const NOISE_PATTERNS = [
428
+ /you have reached your daily quota/i,
429
+ /please wait up to \d+ hours?/i,
430
+ /rate[\s\-]?limit(?:ed)?\s*[—\-:]\s*try again/i,
431
+ /resuming (monitoring|watch|checking)/i,
432
+ /restarting (monitoring|watch)/i,
433
+ ];
434
+ function isNoiseComment(comment) {
435
+ return NOISE_PATTERNS.some((p) => p.test(comment.body));
436
+ }
437
+ function classifyComments(comments) {
438
+ const actionable = [];
439
+ const noiseIds = [];
440
+ for (const c of comments) {
441
+ if (isNoiseComment(c)) {
442
+ noiseIds.push(c.id);
443
+ }
444
+ else {
445
+ actionable.push(c);
446
+ }
447
+ }
448
+ return { actionable, noiseIds };
449
+ }
450
+ function buildResolveCommand(threads, actionableComments, allCommentIds, reviews, checks, prNumber) {
451
+ const argv = ["npx", "pr-shepherd", "resolve", String(prNumber)];
452
+ if (threads.length > 0) {
453
+ argv.push("--resolve-thread-ids", threads.map((t) => t.id).join(","));
454
+ }
455
+ if (allCommentIds.length > 0) {
456
+ argv.push("--minimize-comment-ids", allCommentIds.join(","));
457
+ }
458
+ const hasDismiss = reviews.length > 0;
459
+ if (hasDismiss) {
460
+ argv.push("--dismiss-review-ids", reviews.map((r) => r.id).join(","));
461
+ argv.push("--message", "$DISMISS_MESSAGE");
462
+ }
463
+ // A push happens when there is code to change — threads, actionable comments, CI checks, or reviews.
464
+ // Noise-only comment minimization skips commit/push, so requiresHeadSha must be false.
465
+ const requiresHeadSha = threads.length > 0 || actionableComments.length > 0 || checks.length > 0 || reviews.length > 0;
466
+ // hasMutations = we appended at least one of --resolve-thread-ids,
467
+ // --minimize-comment-ids, or --dismiss-review-ids. Returned explicitly
468
+ // (rather than derived from argv.length) so callers don't couple to the
469
+ // base-argv shape.
470
+ const hasMutations = threads.length > 0 || allCommentIds.length > 0 || reviews.length > 0;
471
+ return { argv, requiresHeadSha, requiresDismissMessage: hasDismiss, hasMutations };
472
+ }
473
+ /**
474
+ * Render a ResolveCommand as a single-line command string for the monitor loop
475
+ * to print or execute. This is NOT a general-purpose POSIX escaper — it wraps
476
+ * the two known placeholders ($DISMISS_MESSAGE, $HEAD_SHA) and any whitespace-
477
+ * bearing arg in double quotes so multi-word values don't split across flags.
478
+ *
479
+ * Contract for callers substituting placeholders: replace the entire quoted
480
+ * token (including the surrounding `"`) with a properly shell-quoted literal.
481
+ * Do not splice raw text inside the existing quotes — the output would then
482
+ * re-expand `$…` / `$(…)` / embedded `"` and break.
483
+ */
484
+ export function renderResolveCommand(rc) {
485
+ // `$HEAD_SHA` is never in `rc.argv` — it is appended pre-quoted below when
486
+ // `requiresHeadSha`. Only `$DISMISS_MESSAGE` (or whitespace-bearing values)
487
+ // need quoting here.
488
+ const needsQuoting = (arg) => arg === "$DISMISS_MESSAGE" || /\s/.test(arg);
489
+ const parts = rc.argv.map((a) => (needsQuoting(a) ? `"${a}"` : a));
490
+ if (rc.requiresHeadSha) {
491
+ parts.push("--require-sha", '"$HEAD_SHA"');
492
+ }
493
+ return parts.join(" ");
494
+ }
495
+ function buildFixInstructions(threads, actionableComments, checks, reviews, baseBranch, resolveCommand, hasConflicts, prNumber) {
496
+ const instructions = [];
497
+ if (threads.length > 0 || actionableComments.length > 0) {
498
+ instructions.push(`Apply code fixes: read and edit each file referenced under \`## Review threads\` and \`## Actionable comments\` above.`);
499
+ }
500
+ // Mirror the truthiness checks in `formatIterateResult` (cli.mts) so each
501
+ // AgentCheck maps to the same bullet shape here as there: runId → runId
502
+ // bullet, else detailsUrl → external bullet, else `(no runId)` bullet.
503
+ const checksWithRunId = checks.filter((c) => c.runId);
504
+ const externalChecks = checks.filter((c) => !c.runId && c.detailsUrl);
505
+ const bareChecks = checks.filter((c) => !c.runId && !c.detailsUrl);
506
+ if (checksWithRunId.length > 0) {
507
+ instructions.push(`For each bullet in \`## Failing checks\` whose backticked locator is a numeric runId (GitHub Actions): run \`gh run view <runId> --log-failed\`, identify the failure, and apply the fix.`);
508
+ }
509
+ if (externalChecks.length > 0) {
510
+ instructions.push(`For each bullet in \`## Failing checks\` starting with \`external\` (external status check): open the linked URL in a browser to inspect the failure — \`gh run view\` cannot fetch logs for external checks.`);
511
+ }
512
+ if (bareChecks.length > 0) {
513
+ instructions.push(`For each bullet in \`## Failing checks\` starting with \`(no runId)\`: there is no run or details URL to inspect. Escalate these to a human — they require manual investigation outside the pr-shepherd flow.`);
514
+ }
515
+ if (reviews.length > 0) {
516
+ instructions.push(`For each bullet under \`## Changes-requested reviews\` above: read the review body and apply the requested changes.`);
517
+ }
518
+ const hasCodeChanges = threads.length > 0 || actionableComments.length > 0 || checks.length > 0 || reviews.length > 0;
519
+ const needsPush = hasCodeChanges || hasConflicts;
520
+ if (hasCodeChanges) {
521
+ instructions.push(`Commit changed files: \`git add <files> && git commit -m "<descriptive message>"\``);
522
+ instructions.push(`Keep the PR title and description current: if the changes alter the PR's scope or intent, run \`gh pr edit ${prNumber} --title "<new title>" --body "<new body>"\` to reflect them. Skip if the existing title/body still accurately describe the PR.`);
523
+ }
524
+ if (needsPush) {
525
+ const captureHint = resolveCommand.requiresHeadSha
526
+ ? ` — capture \`HEAD_SHA=$(git rev-parse HEAD)\``
527
+ : "";
528
+ if (hasConflicts) {
529
+ instructions.push(`Rebase with conflict resolution: run \`git fetch origin && git rebase origin/${baseBranch}\`. If the rebase halts with conflicts, edit the conflicted files to resolve them, \`git add <files>\`, then \`git rebase --continue\`. Repeat until the rebase completes, then \`git push --force-with-lease\`${captureHint}.`);
530
+ }
531
+ else {
532
+ instructions.push(`Rebase and push: \`git fetch origin && git rebase origin/${baseBranch} && git push --force-with-lease\`${captureHint}`);
533
+ }
534
+ }
535
+ // Only tell the agent to run `resolve:` if the command actually mutates
536
+ // GitHub state. A CONFLICTS-only flow has nothing to mutate on GitHub.
537
+ if (resolveCommand.hasMutations) {
538
+ const substituteParts = [];
539
+ if (resolveCommand.requiresHeadSha) {
540
+ substituteParts.push(`"$HEAD_SHA" with the pushed commit SHA`);
541
+ }
542
+ if (resolveCommand.requiresDismissMessage) {
543
+ substituteParts.push(`$DISMISS_MESSAGE with a one-sentence description of what you changed`);
544
+ }
545
+ const substituteHint = substituteParts.length > 0 ? `, substituting ${substituteParts.join(" and ")}` : "";
546
+ instructions.push(`Run the \`resolve:\` command shown above${substituteHint}.`);
547
+ }
548
+ return instructions;
549
+ }
550
+ function buildWaitLog(base) {
551
+ const { summary, mergeStateStatus, remainingSeconds } = base;
552
+ const parts = [`WAIT: ${summary.passing} passing, ${summary.inProgress} in-progress`];
553
+ switch (mergeStateStatus) {
554
+ case "BEHIND":
555
+ parts.push("branch is behind base");
556
+ break;
557
+ case "BLOCKED":
558
+ parts.push("blocked by pending reviews or required status checks");
559
+ break;
560
+ case "DRAFT":
561
+ parts.push("PR is a draft");
562
+ break;
563
+ case "UNSTABLE":
564
+ parts.push("some checks are unstable");
565
+ break;
566
+ }
567
+ if (remainingSeconds > 0) {
568
+ parts.push(`${remainingSeconds}s until auto-cancel`);
569
+ }
570
+ return parts.join(" — ");
571
+ }
572
+ function buildEscalateHumanMessage(escalate, pr) {
573
+ const lines = [];
574
+ lines.push("⚠️ /pr-shepherd:monitor paused — needs human direction");
575
+ lines.push("");
576
+ lines.push(`**Triggers:** ${escalate.triggers.map((t) => `\`${t}\``).join(", ")}`);
577
+ lines.push("");
578
+ lines.push(escalate.suggestion);
579
+ const hasItems = escalate.unresolvedThreads.length > 0 ||
580
+ escalate.changesRequestedReviews.length > 0 ||
581
+ escalate.ambiguousComments.length > 0;
582
+ if (hasItems) {
583
+ lines.push("");
584
+ lines.push("## Items needing attention");
585
+ for (const t of escalate.unresolvedThreads) {
586
+ const loc = t.path ? `\`${t.path}:${t.line ?? "?"}\`` : "(no location)";
587
+ const firstLine = t.body.split("\n")[0] ?? "";
588
+ lines.push(`- thread \`${t.id}\` — ${loc} (@${t.author}): ${firstLine}`);
589
+ }
590
+ for (const r of escalate.changesRequestedReviews) {
591
+ const firstLine = r.body.split("\n")[0] ?? "";
592
+ lines.push(`- review \`${r.id}\` (@${r.author}): ${firstLine}`);
593
+ }
594
+ for (const c of escalate.ambiguousComments) {
595
+ const firstLine = c.body.split("\n")[0] ?? "";
596
+ lines.push(`- comment \`${c.id}\` (@${c.author}): ${firstLine}`);
597
+ }
598
+ }
599
+ if (escalate.attemptHistory && escalate.attemptHistory.length > 0) {
600
+ lines.push("");
601
+ lines.push("## Fix attempts");
602
+ for (const a of escalate.attemptHistory) {
603
+ lines.push(`- thread \`${a.threadId}\` attempted ${a.attempts} times`);
604
+ }
605
+ }
606
+ lines.push("");
607
+ lines.push("---");
608
+ lines.push("");
609
+ lines.push(`Run \`/pr-shepherd:check ${pr}\` to see current state.`);
610
+ lines.push(`After fixing manually, rerun \`/pr-shepherd:monitor ${pr}\` to resume.`);
611
+ return lines.join("\n");
612
+ }
613
+ function buildEscalateSuggestion(triggers, failureReason) {
614
+ if (triggers.includes("base-branch-unknown")) {
615
+ const reason = failureReason ? ` (${failureReason})` : "";
616
+ return `Could not determine the PR's base branch${reason} — refusing to emit a rebase that could force-push onto the wrong base. Run the rebase manually against the PR's real target branch.`;
617
+ }
291
618
  if (triggers.includes("fix-thrash")) {
292
619
  return "Same thread(s) attempted multiple times without resolution — fix manually then rerun /pr-shepherd:monitor";
293
620
  }
@@ -17,6 +17,7 @@ import { getRepoInfo, getCurrentPrNumber } from "../github/client.mjs";
17
17
  import { fetchPrBatch } from "../github/batch.mjs";
18
18
  import { getOutdatedThreads } from "../comments/outdated.mjs";
19
19
  import { autoResolveOutdated, applyResolveOptions } from "../comments/resolve.mjs";
20
+ import { loadConfig } from "../config/load.mjs";
20
21
  /**
21
22
  * Fetch mode: auto-resolve outdated threads and return all active items for LLM triage.
22
23
  */
@@ -39,10 +40,12 @@ export async function runResolveFetch(opts) {
39
40
  }
40
41
  }
41
42
  const activeThreads = unresolvedThreads.filter((t) => !t.isOutdated);
43
+ const cfg = loadConfig();
42
44
  return {
43
- actionableThreads: activeThreads.map(({ isResolved, isOutdated, ...rest }) => rest),
45
+ actionableThreads: activeThreads.map(({ isResolved: _r, isOutdated: _o, ...rest }) => rest),
44
46
  actionableComments: visibleComments,
45
47
  changesRequestedReviews: data.changesRequestedReviews,
48
+ reviewSummaries: cfg.resolve.fetchReviewSummaries ? data.reviewSummaries : [],
46
49
  };
47
50
  }
48
51
  /**
@@ -41,6 +41,11 @@ function deepMerge(base, override) {
41
41
  // ---------------------------------------------------------------------------
42
42
  function applyCompat(raw) {
43
43
  const out = { ...raw };
44
+ // Removed top-level sections — warn and strip.
45
+ if ("execution" in out) {
46
+ process.stderr.write(`pr-shepherd: config section "execution" (maxBufferMb, triageLogBufferMb) has been removed and has no effect.\n`);
47
+ delete out["execution"];
48
+ }
44
49
  // Removed keys — warn and strip.
45
50
  for (const gone of ["baseBranch", "minimizeBots", "cancelCiOnFailure", "autoMinimize"]) {
46
51
  if (gone in out) {
package/bin/config.json CHANGED
@@ -17,7 +17,8 @@
17
17
  "shaPoll": {
18
18
  "intervalMs": 2000,
19
19
  "maxAttempts": 10
20
- }
20
+ },
21
+ "fetchReviewSummaries": true
21
22
  },
22
23
  "checks": {
23
24
  "ciTriggerEvents": ["pull_request", "pull_request_target"],
@@ -40,10 +41,6 @@
40
41
  "mergeStatus": {
41
42
  "blockingReviewerLogins": ["copilot"]
42
43
  },
43
- "execution": {
44
- "maxBufferMb": 10,
45
- "triageLogBufferMb": 5
46
- },
47
44
  "actions": {
48
45
  "autoResolveOutdated": true,
49
46
  "autoRebase": true,
@@ -60,23 +60,41 @@ export async function fetchPrBatch(pr, repo) {
60
60
  }, raw.comments.pageInfo.startCursor);
61
61
  rawCommentNodes = [...extra, ...rawCommentNodes];
62
62
  }
63
- // Paginate reviews backward if the first page is incomplete.
64
- let rawReviewNodes = raw.reviews.nodes;
65
- if (raw.reviews.pageInfo.hasPreviousPage && raw.reviews.pageInfo.startCursor) {
63
+ // Paginate CHANGES_REQUESTED reviews backward if the first page is incomplete.
64
+ let rawReviewNodes = raw.changesRequestedReviews.nodes;
65
+ if (raw.changesRequestedReviews.pageInfo.hasPreviousPage &&
66
+ raw.changesRequestedReviews.pageInfo.startCursor) {
66
67
  const extra = await paginateBackward(async (cursor) => {
67
68
  const res = await graphql(BATCH_PR_QUERY, {
68
69
  owner: repo.owner,
69
70
  repo: repo.name,
70
71
  pr,
71
- ...(cursor ? { reviewsCursor: cursor } : {}),
72
+ ...(cursor ? { changesRequestedCursor: cursor } : {}),
72
73
  });
73
74
  const pr2 = res.data.repository.pullRequest;
74
75
  if (!pr2)
75
76
  throw new Error(`PR #${pr} not found`);
76
- return pr2.reviews;
77
- }, raw.reviews.pageInfo.startCursor);
77
+ return pr2.changesRequestedReviews;
78
+ }, raw.changesRequestedReviews.pageInfo.startCursor);
78
79
  rawReviewNodes = [...extra, ...rawReviewNodes];
79
80
  }
81
+ // Paginate COMMENTED review summaries backward if the first page is incomplete.
82
+ let rawReviewSummaryNodes = raw.reviewSummaries.nodes;
83
+ if (raw.reviewSummaries.pageInfo.hasPreviousPage && raw.reviewSummaries.pageInfo.startCursor) {
84
+ const extra = await paginateBackward(async (cursor) => {
85
+ const res = await graphql(BATCH_PR_QUERY, {
86
+ owner: repo.owner,
87
+ repo: repo.name,
88
+ pr,
89
+ ...(cursor ? { reviewSummariesCursor: cursor } : {}),
90
+ });
91
+ const pr2 = res.data.repository.pullRequest;
92
+ if (!pr2)
93
+ throw new Error(`PR #${pr} not found`);
94
+ return pr2.reviewSummaries;
95
+ }, raw.reviewSummaries.pageInfo.startCursor);
96
+ rawReviewSummaryNodes = [...extra, ...rawReviewSummaryNodes];
97
+ }
80
98
  // Paginate check contexts forward if the first page is incomplete.
81
99
  let rawCheckNodes = raw.commits.nodes[0]?.commit.statusCheckRollup?.contexts.nodes ?? [];
82
100
  const checksPageInfo = raw.commits.nodes[0]?.commit.statusCheckRollup?.contexts.pageInfo;
@@ -96,13 +114,13 @@ export async function fetchPrBatch(pr, repo) {
96
114
  }, checksPageInfo.endCursor);
97
115
  rawCheckNodes = [...rawCheckNodes, ...extra];
98
116
  }
99
- const data = parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes, rawCheckNodes);
117
+ const data = parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes, rawReviewSummaryNodes, rawCheckNodes);
100
118
  return { data, rateLimit: result.rateLimit };
101
119
  }
102
120
  // ---------------------------------------------------------------------------
103
121
  // Parsers
104
122
  // ---------------------------------------------------------------------------
105
- function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes, rawCheckNodes) {
123
+ function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes, rawReviewSummaryNodes, rawCheckNodes) {
106
124
  const reviewRequests = (raw.reviewRequests?.nodes ?? []).flatMap((n) => {
107
125
  const login = n.requestedReviewer?.login ?? n.requestedReviewer?.name;
108
126
  return login ? [{ login }] : [];
@@ -137,6 +155,13 @@ function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes, rawChe
137
155
  author: r.author?.login ?? "unknown",
138
156
  body: r.body,
139
157
  }));
158
+ const reviewSummaries = rawReviewSummaryNodes
159
+ .filter((r) => !r.isMinimized && r.body.trim() !== "")
160
+ .map((r) => ({
161
+ id: r.id,
162
+ author: r.author?.login ?? "unknown",
163
+ body: r.body,
164
+ }));
140
165
  const checks = rawCheckNodes.flatMap((node) => {
141
166
  if (node.__typename === "CheckRun") {
142
167
  const event = node.checkSuite?.workflowRun?.event ?? null;
@@ -168,6 +193,7 @@ function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes, rawChe
168
193
  return [];
169
194
  });
170
195
  return {
196
+ nodeId: raw.id,
171
197
  number: raw.number,
172
198
  state: raw.state,
173
199
  isDraft: raw.isDraft,
@@ -175,11 +201,13 @@ function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes, rawChe
175
201
  mergeStateStatus: raw.mergeStateStatus,
176
202
  reviewDecision: (raw.reviewDecision ?? null),
177
203
  headRefOid: raw.headRefOid,
204
+ baseRefName: raw.baseRefName,
178
205
  reviewRequests,
179
206
  latestReviews,
180
207
  reviewThreads,
181
208
  comments,
182
209
  changesRequestedReviews,
210
+ reviewSummaries,
183
211
  checks,
184
212
  };
185
213
  }